Vault.tsx

26.3 KB · raw

1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon";5import { popupCopy } from "../copy";6import {7  filterAndSortVaultEntries,8  entryMatchesDomain,9  VAULT_SORT_OPTIONS,10  type SortOrder,11} from "./vault-search";1213export type { SortOrder } from "./vault-search";1415function vibrate(ms: number) {16  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }17}1819function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {20  const src = props.domain && !props.isNote && !props.isPasskey21    ? faviconUrl(props.domain)22    : null;23  const [isReal, setIsReal] = createSignal(false);2425  // Show InitialsChip (or passkey/note icon) first — no globe flash.26  // Load the favicon invisibly in the background; switch to it only after27  // confirming it differs from Chrome's default "no favicon" globe image.28  return (29    <Show30      when={isReal()}31      fallback={32        <>33          {props.isPasskey34            ? <PasskeyIcon />35            : props.isNote36              ? <NoteIcon />37              : <InitialsChip title={props.title ?? props.domain ?? ""} />}38          {src && (39            <img40              src={src}41              style="display:none"42              onError={() => {}}43              onLoad={async (e) => {44                const img = e.currentTarget as HTMLImageElement;45                // Old Chrome returns the globe at native 16×16 even when46                // size=32 is requested — treat as no favicon.47                if (img.naturalWidth <= 16) return;48                // New Chrome returns the globe scaled to the requested size.49                // Compare pixel data against the known "no favicon" reference.50                const sig = faviconImgSig(img);51                if (sig !== null) {52                  const gSig = await globeFaviconSig();53                  if (gSig !== null && sig === gSig) return;54                }55                setIsReal(true);56              }}57            />58          )}59        </>60      }61    >62      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />63    </Show>64  );65}666768function InitialsChip(props: { title: string }) {69  return (70    <span71      class="peach-favicon-initials"72      style={{ background: titleBackground(props.title) }}73      aria-hidden="true"74    >75      {titleInitials(props.title)}76    </span>77  );78}798081const PasskeyIcon = () => (82  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">83    <circle cx="4" cy="4.5" r="3" />84    <path d="M6.5 7l3.5 3.5M8.5 8.5L10 10" />85  </svg>86);8788const NoteIcon = () => (89  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">90    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />91    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />92  </svg>93);9495interface VaultProps {96  entries: VaultEntry[];97  selectedEntryId?: string;98  currentTabDomain?: string;99  sortOrder: SortOrder;100  searchSeed?: string;101  searchSeedNonce?: number;102  // When true, render the mobile flat top bar: no back arrow (use swipe-down103  // on the bottom sheet), search-glass icon on the left, counter on the104  // right. Matches the extension's `.peach-search-bar` look.105  isNative?: boolean;106  onSelectEntry: (entry: VaultEntry) => void;107  onSortOrderChange: (order: SortOrder) => void;108  onAdd: () => void;109  onClose: () => void;110  onOpenTrash?: () => void;111  onDeleteMany?: (ids: string[]) => Promise<void>;112}113114export function VaultView(props: VaultProps) {115  const [search, setSearch] = createSignal("");116  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());117  const [notesOnly, setNotesOnly] = createSignal(false);118  const [selecting, setSelecting] = createSignal(false);119  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());120  const [deleting, setDeleting] = createSignal(false);121  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);122  const [flashId, setFlashId] = createSignal<string | null>(null);123  const [scrollTop, setScrollTop] = createSignal(0);124  const [viewportHeight, setViewportHeight] = createSignal(600);125  let longPressTimer: number | null = null;126  let touchStart: { x: number; y: number; t: number } | null = null;127  let suppressNextClick = false;128129  function clearLongPressTimer() {130    if (longPressTimer !== null) {131      window.clearTimeout(longPressTimer);132      longPressTimer = null;133    }134  }135136  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {137    if (selecting()) return;138    const t = e.touches[0];139    if (!t) return;140    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };141    clearLongPressTimer();142    longPressTimer = window.setTimeout(() => {143      suppressNextClick = true;144      vibrate(20);145      setContextEntry(entry);146      longPressTimer = null;147    }, 500);148  }149150  function handleRowTouchMove(e: TouchEvent) {151    if (!touchStart) return;152    const t = e.touches[0];153    if (!t) return;154    const dx = Math.abs(t.clientX - touchStart.x);155    const dy = Math.abs(t.clientY - touchStart.y);156    if (dx > 8 || dy > 8) clearLongPressTimer();157  }158159  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {160    clearLongPressTimer();161    if (!touchStart) return;162    const t = e.changedTouches[0];163    if (!t) { touchStart = null; return; }164    const dx = t.clientX - touchStart.x;165    const dy = Math.abs(t.clientY - touchStart.y);166    const dt = Date.now() - touchStart.t;167    touchStart = null;168    // Right-swipe-to-copy: only on compact + login entries with a password169    const isCompact = !document.body.classList.contains("layout-medium")170      && !document.body.classList.contains("layout-expanded");171    if (172      isCompact173      && !selecting()174      && dx > 80175      && dy < 30176      && dt < 400177      && entry.type === "login"178      && entry.password179    ) {180      suppressNextClick = true;181      vibrate(15);182      setFlashId(entry.id);183      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);184      await popupCopy(entry.password, "password");185    }186  }187188  let searchRef: HTMLInputElement | undefined;189  let listRef: HTMLDivElement | undefined;190191  const focusSearch = (el: HTMLInputElement) => {192    searchRef = el;193    setTimeout(() => el.focus(), 50);194  };195196  createEffect(() => {197    void props.searchSeedNonce;198    const seed = props.searchSeed;199    if (!seed) return;200    setSearch(seed);201    requestAnimationFrame(() => {202      searchRef?.focus();203      searchRef?.setSelectionRange(seed.length, seed.length);204    });205  });206207  function enterSelectMode() {208    setSelecting(true);209    setSelectedIds(new Set<string>());210  }211212  function exitSelectMode() {213    setSelecting(false);214    setSelectedIds(new Set<string>());215  }216217  function toggleId(id: string) {218    const s = new Set<string>(selectedIds());219    if (s.has(id)) s.delete(id); else s.add(id);220    setSelectedIds(s);221  }222223  const allTags = createMemo(() => {224    const tagSet = new Set<string>();225    for (const entry of props.entries) {226      for (const tag of entry.tags ?? []) {227        tagSet.add(tag);228      }229    }230    return Array.from(tagSet).sort();231  });232233  const hasTags = createMemo(() => allTags().length > 0);234235  function toggleTag(tag: string) {236    const current = new Set(selectedTags());237    if (current.has(tag)) {238      current.delete(tag);239    } else {240      current.add(tag);241    }242    setSelectedTags(current);243  }244245  const filteredEntries = createMemo(() => {246    return filterAndSortVaultEntries(props.entries, {247      notesOnly: notesOnly(),248      selectedTags: selectedTags(),249      query: search(),250      searchScope: "both",251      sortOrder: props.sortOrder,252      currentTabDomain: props.currentTabDomain,253    });254  });255256  // Declaration order matters: `allVisibleIds` and `allSelected` read257  // `filteredEntries` eagerly on component mount via createMemo. Moving258  // `filteredEntries` below them throws ReferenceError under the production259  // minifier and freezes the vault list on mount (v1.4.34 regression).260  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));261  const allSelected = createMemo(() =>262    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))263  );264265  function toggleSelectAll() {266    if (allSelected()) {267      setSelectedIds(new Set<string>());268    } else {269      setSelectedIds(new Set<string>(allVisibleIds()));270    }271  }272273  async function handleDeleteSelected() {274    const ids = Array.from(selectedIds());275    if (!ids.length || !props.onDeleteMany) return;276    setDeleting(true);277    await props.onDeleteMany(ids);278    setDeleting(false);279    exitSelectMode();280  }281282  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.283  // Above it, we render only a window around the visible viewport plus a284  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty285  // space. Row height is approximate (variable padding for badges) but286  // bounded — the buffer absorbs over/undershoot.287  const VIRTUAL_THRESHOLD = 100;288  const ROW_HEIGHT = 56;289  const BUFFER_ROWS = 20;290291  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);292293  const visibleRange = createMemo(() => {294    const total = filteredEntries().length;295    if (!isVirtualized()) return { start: 0, end: total };296    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);297    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);298    return { start, end };299  });300301  const windowedEntries = createMemo(() => {302    const { start, end } = visibleRange();303    return filteredEntries().slice(start, end);304  });305306  function handleListScroll(e: Event) {307    if (!isVirtualized()) return;308    const target = e.currentTarget as HTMLDivElement;309    setScrollTop(target.scrollTop);310    setViewportHeight(target.clientHeight);311  }312313  function handleSearchKeydown(e: KeyboardEvent) {314    if (e.key === "ArrowDown") {315      e.preventDefault();316      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");317      firstBtn?.focus();318    } else if (e.key === "ArrowUp") {319      e.preventDefault();320      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");321      allBtns?.[allBtns.length - 1]?.focus();322    } else if (e.key === "Enter") {323      e.preventDefault();324      const firstEntry = filteredEntries()[0];325      if (firstEntry) props.onSelectEntry(firstEntry);326    } else if (e.key === "Escape" && selecting()) {327      exitSelectMode();328    }329  }330331  function handleListKeydown(e: KeyboardEvent) {332    if (!listRef) return;333    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));334    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);335    if (e.key === "ArrowDown") {336      e.preventDefault();337      if (idx < btns.length - 1) btns[idx + 1]?.focus();338    } else if (e.key === "ArrowUp") {339      e.preventDefault();340      if (idx === 0) { searchRef?.focus(); }341      else btns[idx - 1]?.focus();342    } else if (e.key === "Escape") {343      if (selecting()) exitSelectMode();344      else props.onClose();345    }346  }347348  return (349    <div class="peach-overlay-view ov-enter-up" style="display:flex">350      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>351        <Show352          when={!props.isNative}353          fallback={354            <span class="peach-search-icon" aria-hidden="true">355              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">356                <circle cx="5.5" cy="5.5" r="3.75" />357                <path d="M8.75 8.75L11.5 11.5" />358              </svg>359            </span>360          }361        >362          <button363            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"364            onClick={props.onClose}365            aria-label="Back"366          >367            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">368              <path d="M6 1L1 6L6 11" />369            </svg>370          </button>371        </Show>372        <input373          class="peach-search-input"374          placeholder="search vault..."375          autocomplete="off"376          style={props.isNative ? undefined : "font-size:14px"}377          ref={focusSearch}378          value={search()}379          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}380          onKeyDown={handleSearchKeydown}381        />382        <span class="peach-search-counter peach-search-counter-list">383          {search() || selectedTags().size > 0384            ? `${filteredEntries().length} of ${props.entries.length}`385            : props.isNative386              ? `${props.entries.length}`387              : `${props.entries.length} entries`}388        </span>389      </div>390391      {/* Sort bar — morphs into select-mode controls */}392      <div class="peach-sort-bar">393        <Show394          when={selecting()}395          fallback={396            <>397              <For each={VAULT_SORT_OPTIONS}>398                {({ key, label }) => (399                  <button400                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}401                    onClick={() => props.onSortOrderChange(key)}402                  >403                    {label}404                  </button>405                )}406              </For>407              <span style="flex:1" />408              <button409                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}410                onClick={() => setNotesOnly(v => !v)}411                aria-label="Notes only"412                title="Show notes only"413              >414                <NoteIcon />415              </button>416              <Show when={props.onOpenTrash}>417                <button418                  class="peach-sort-trash-btn"419                  onClick={props.onOpenTrash}420                  aria-label="View trash"421                  title="View deleted entries"422                >423                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">424                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />425                  </svg>426                </button>427              </Show>428              <Show when={props.onDeleteMany}>429                <button430                  class="peach-sort-trash-btn"431                  onClick={enterSelectMode}432                  aria-label="Select entries"433                  title="Select entries"434                >435                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">436                    <circle cx="4" cy="4" r="2.5" />437                    <circle cx="9" cy="4" r="2.5" />438                    <circle cx="4" cy="9" r="2.5" />439                    <circle cx="9" cy="9" r="2.5" />440                  </svg>441                </button>442              </Show>443            </>444          }445        >446          {/* Select mode sort bar */}447          <button448            class="peach-select-cancel-btn"449            onClick={exitSelectMode}450          >451            cancel452          </button>453          <span class="peach-select-count-label">454            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}455          </span>456          <button457            class="peach-select-all-btn"458            onClick={toggleSelectAll}459          >460            {allSelected() ? "none" : "all"}461          </button>462        </Show>463      </div>464465      <Show when={hasTags() && !selecting()}>466        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">467          <For each={allTags()}>468            {(tag) => (469              <button470                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}471                onClick={() => toggleTag(tag)}472              >473                {tag}474              </button>475            )}476          </For>477        </div>478      </Show>479480      <div481        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}482        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}483        onKeyDown={handleListKeydown}484        onScroll={handleListScroll}485      >486        <Show487          when={filteredEntries().length > 0}488          fallback={489            <div class="peach-list-empty">490              <span>no entries yet</span>491            </div>492          }493        >494          <Show when={isVirtualized()}>495            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />496          </Show>497          <For each={windowedEntries()}>498            {(entry, localIndex) => {499              const index = () => visibleRange().start + localIndex();500              const isNote = entry.type === "note";501              const isPasskey = entry.passkey !== undefined;502              const domain = entry.type === "note"503                ? (entry.notes?.slice(0, 40) ?? "note")504                : entry.url505                  ? getDisplayDomain(entry.url)506                  : entry.passkey?.rpId507                    ? entry.passkey.rpId508                  : "saved entry";509              const isTabMatch = () =>510                props.currentTabDomain !== undefined &&511                props.currentTabDomain !== "" &&512                entryMatchesDomain(entry, props.currentTabDomain);513              const isSelected = () => selectedIds().has(entry.id);514515              const ariaLabel = (() => {516                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";517                const sub = entry.username || domain || "";518                return sub519                  ? `${entry.title} — ${sub} (${tag})`520                  : `${entry.title} (${tag})`;521              })();522              return (523                <button524                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}${flashId() === entry.id ? " is-copy-flash" : ""}`}525                  style={isVirtualized()526                    ? { animation: "none" }527                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }528                  }529                  aria-label={ariaLabel}530                  aria-pressed={selecting() ? isSelected() : undefined}531                  onTouchStart={(e) => handleRowTouchStart(entry, e)}532                  onTouchMove={handleRowTouchMove}533                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}534                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}535                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}536                  onClick={() => {537                    if (suppressNextClick) { suppressNextClick = false; return; }538                    if (selecting()) {539                      toggleId(entry.id);540                    } else {541                      props.onSelectEntry(entry);542                    }543                  }}544                >545                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>546                    <Show547                      when={selecting()}548                      fallback={549                        <FaviconIcon550                          domain={entry.url ? getDisplayDomain(entry.url) : ""}551                          title={entry.title}552                          isNote={isNote}553                          isPasskey={isPasskey}554                        />555                      }556                    >557                      {/* Check indicator */}558                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">559                        <circle560                          cx="8" cy="8" r="7"561                          stroke="currentColor"562                          stroke-width="1"563                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}564                          opacity={isSelected() ? "1" : "0.3"}565                        />566                        <Show when={isSelected()}>567                          <path568                            d="M5 8.5L7 10.5L11 6"569                            stroke="white"570                            stroke-width="1.2"571                            stroke-linecap="round"572                            stroke-linejoin="round"573                          />574                        </Show>575                      </svg>576                    </Show>577                  </div>578                  <div class="peach-entry-row-info">579                    <span class="peach-entry-row-name">580                      {entry.title}581                      <Show when={isPasskey && !selecting()}>582                        <span class="peach-passkey-badge">passkey</span>583                      </Show>584                      <Show when={isTabMatch() && !selecting()}>585                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>586                      </Show>587                    </span>588                    <span class="peach-entry-row-meta">{domain}</span>589                  </div>590                  <Show when={entry.breach && !selecting()}>591                    <span class="peach-entry-row-dot" aria-label="breach"></span>592                  </Show>593                  <Show when={entry.canary && !selecting()}>594                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>595                  </Show>596                </button>597              );598            }}599          </For>600          <Show when={isVirtualized()}>601            <div602              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}603              aria-hidden="true"604            />605          </Show>606        </Show>607      </div>608609      {/* Bottom bar — new entry normally, delete action in select mode */}610      <Show611        when={selecting()}612        fallback={613          <button class="peach-new-entry-row" onClick={props.onAdd}>614            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">615              <path d="M5 1v8M1 5h8" />616            </svg>617            new entry618          </button>619        }620      >621        <button622          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}623          disabled={selectedIds().size === 0 || deleting()}624          onClick={() => void handleDeleteSelected()}625        >626          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">627            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />628          </svg>629          <Show630            when={selectedIds().size > 0}631            fallback={<span>select entries to delete</span>}632          >633            <span>634              {deleting()635                ? "moving to trash…"636                : `move ${selectedIds().size} to trash`}637            </span>638          </Show>639        </button>640      </Show>641642      <Show when={contextEntry() !== null}>643        <div644          class="peach-context-menu-backdrop"645          onClick={() => setContextEntry(null)}646          role="presentation"647        >648          <div649            class="peach-context-menu"650            role="menu"651            aria-label="Entry actions"652            onClick={(e) => e.stopPropagation()}653          >654            <Show when={contextEntry()!.username}>655              <button656                role="menuitem"657                class="peach-context-menu-item"658                onClick={async () => {659                  const e = contextEntry();660                  if (!e?.username) return;661                  await popupCopy(e.username, "username");662                  setContextEntry(null);663                }}664              >665                Copy username666              </button>667            </Show>668            <Show when={contextEntry()!.password}>669              <button670                role="menuitem"671                class="peach-context-menu-item"672                onClick={async () => {673                  const e = contextEntry();674                  if (!e?.password) return;675                  await popupCopy(e.password, "password");676                  setContextEntry(null);677                }}678              >679                Copy password680              </button>681            </Show>682            <Show when={contextEntry()!.totp}>683              <button684                role="menuitem"685                class="peach-context-menu-item"686                onClick={async () => {687                  const e = contextEntry();688                  if (!e?.totp) return;689                  await popupCopy(e.totp, "TOTP secret");690                  setContextEntry(null);691                }}692              >693                Copy TOTP secret694              </button>695            </Show>696            <button697              role="menuitem"698              class="peach-context-menu-item"699              onClick={() => {700                const e = contextEntry();701                if (e) props.onSelectEntry(e);702                setContextEntry(null);703              }}704            >705              Open706            </button>707            <Show when={props.onDeleteMany}>708              <button709                role="menuitem"710                class="peach-context-menu-item peach-context-menu-item--danger"711                onClick={async () => {712                  const e = contextEntry();713                  if (!e) return;714                  setContextEntry(null);715                  await props.onDeleteMany?.([e.id]);716                }}717              >718                Move to trash719              </button>720            </Show>721            <button722              role="menuitem"723              class="peach-context-menu-item peach-context-menu-item--cancel"724              onClick={() => setContextEntry(null)}725            >726              Cancel727            </button>728          </div>729        </div>730      </Show>731    </div>732  );733}