| 1 | <script lang="ts"> |
| 2 | import { onMount } from 'svelte'; |
| 3 | import { messageMediaUrl } from './api'; |
| 4 | import MxcAvatar from './MxcAvatar.svelte'; |
| 5 | import { packLabel, stickersOf } from './packs'; |
| 6 | import type { ImagePack, PackImage } from './types'; |
| 7 | |
| 8 | export let packs: ImagePack[]; |
| 9 | export let roomName: (roomId: string) => string; |
| 10 | export let onSend: (pack: ImagePack, image: PackImage) => void; |
| 11 | export let onClone: (pack: ImagePack, image: PackImage, x: number, y: number) => void; |
| 12 | export let onClonePack: (pack: ImagePack, x: number, y: number) => void; |
| 13 | export let onManage: () => void; |
| 14 | export let onClose: () => void; |
| 15 | |
| 16 | let root: HTMLDivElement; |
| 17 | let search: HTMLInputElement; |
| 18 | let query = ''; |
| 19 | let activeId: string | null = null; |
| 20 | |
| 21 | $: withStickers = packs.filter((p) => stickersOf(p).length > 0); |
| 22 | $: q = query.trim().toLowerCase(); |
| 23 | $: shown = withStickers |
| 24 | .map((pack) => ({ |
| 25 | pack, |
| 26 | images: stickersOf(pack).filter( |
| 27 | (i) => !q || i.shortcode.toLowerCase().includes(q) || (i.body ?? '').toLowerCase().includes(q) |
| 28 | ) |
| 29 | })) |
| 30 | .filter((g) => g.images.length > 0 && (q || activeId === null || g.pack.id === activeId)); |
| 31 | |
| 32 | onMount(() => search?.focus()); |
| 33 | |
| 34 | function outside(ev: PointerEvent): void { |
| 35 | if (!root.contains(ev.target as Node) && !(ev.target as Element).closest?.('.context-menu, .sticker-btn')) onClose(); |
| 36 | } |
| 37 | |
| 38 | function key(ev: KeyboardEvent): void { |
| 39 | if (ev.key === 'Escape' && !document.querySelector('.context-menu')) { |
| 40 | ev.stopPropagation(); |
| 41 | onClose(); |
| 42 | } |
| 43 | } |
| 44 | </script> |
| 45 | |
| 46 | <svelte:window on:pointerdown|capture={outside} on:keydown|capture={key} /> |
| 47 | |
| 48 | <div class="sticker-picker" role="dialog" aria-label="Stickers" bind:this={root}> |
| 49 | <div class="top"> |
| 50 | <input class="search" type="search" placeholder="Search stickers" bind:value={query} bind:this={search} /> |
| 51 | <button type="button" class="icon-btn" title="Manage packs" aria-label="Manage packs" on:click={onManage}> |
| 52 | <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg> |
| 53 | </button> |
| 54 | </div> |
| 55 | {#if withStickers.length > 1} |
| 56 | <div class="tabs" role="tablist" aria-label="Packs"> |
| 57 | <button type="button" role="tab" class="tab all" aria-selected={activeId === null} on:click={() => (activeId = null)}>All</button> |
| 58 | {#each withStickers as pack (pack.id)} |
| 59 | <button |
| 60 | type="button" |
| 61 | role="tab" |
| 62 | class="tab" |
| 63 | aria-selected={activeId === pack.id} |
| 64 | title={packLabel(pack, roomName)} |
| 65 | on:click={() => (activeId = pack.id)} |
| 66 | > |
| 67 | {#if pack.avatar} |
| 68 | <MxcAvatar mxc={pack.avatar} label={packLabel(pack, roomName)} size={24} shape="rounded" /> |
| 69 | {:else} |
| 70 | <img src={messageMediaUrl(stickersOf(pack)[0].media_id, 'Thumbnail')} alt={packLabel(pack, roomName)} /> |
| 71 | {/if} |
| 72 | </button> |
| 73 | {/each} |
| 74 | </div> |
| 75 | {/if} |
| 76 | <div class="scroll"> |
| 77 | {#each shown as g (g.pack.id)} |
| 78 | <section class="group" aria-label={packLabel(g.pack, roomName)}> |
| 79 | <div class="group-head"> |
| 80 | <h3>{packLabel(g.pack, roomName)}</h3> |
| 81 | {#if !g.pack.editable} |
| 82 | <button type="button" class="clone-pack" on:click={(ev) => onClonePack(g.pack, ev.clientX, ev.clientY)}>Clone pack</button> |
| 83 | {/if} |
| 84 | </div> |
| 85 | <div class="grid"> |
| 86 | {#each g.images as image (image.shortcode)} |
| 87 | <button |
| 88 | type="button" |
| 89 | class="cell" |
| 90 | title={image.body ?? image.shortcode} |
| 91 | aria-label={image.body ?? image.shortcode} |
| 92 | on:click={() => onSend(g.pack, image)} |
| 93 | on:contextmenu|preventDefault={(ev) => onClone(g.pack, image, ev.clientX, ev.clientY)} |
| 94 | > |
| 95 | <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt="" loading="lazy" /> |
| 96 | </button> |
| 97 | {/each} |
| 98 | </div> |
| 99 | </section> |
| 100 | {:else} |
| 101 | {#if withStickers.length === 0} |
| 102 | <div class="empty"> |
| 103 | <p class="status">No stickers yet</p> |
| 104 | <button type="button" class="primary-btn" on:click={onManage}>Add a pack</button> |
| 105 | </div> |
| 106 | {:else} |
| 107 | <p class="status">No stickers match "{query.trim()}"</p> |
| 108 | {/if} |
| 109 | {/each} |
| 110 | </div> |
| 111 | </div> |
| 112 | |
| 113 | <style> |
| 114 | .sticker-picker { |
| 115 | display: flex; |
| 116 | flex-direction: column; |
| 117 | gap: 6px; |
| 118 | width: 360px; |
| 119 | height: auto; |
| 120 | max-height: min(420px, calc(100vh - 120px)); |
| 121 | max-width: calc(100vw - 16px); |
| 122 | padding: 8px; |
| 123 | background: var(--panel); |
| 124 | border: 1px solid var(--border); |
| 125 | border-radius: var(--r-md); |
| 126 | box-shadow: 0 6px 24px rgb(0 0 0 / 0.5); |
| 127 | } |
| 128 | .top { display: flex; gap: 6px; } |
| 129 | .search { |
| 130 | flex: 1; |
| 131 | background: var(--bg-soft); |
| 132 | border: 1px solid var(--border); |
| 133 | border-radius: var(--r-md); |
| 134 | color: var(--text); |
| 135 | padding: 6px 10px; |
| 136 | font-size: 13px; |
| 137 | } |
| 138 | .search:focus-visible { outline: none; border-color: var(--accent); } |
| 139 | .tabs { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 2px; } |
| 140 | .tab { |
| 141 | flex: none; |
| 142 | display: grid; |
| 143 | place-items: center; |
| 144 | min-width: 32px; |
| 145 | height: 32px; |
| 146 | padding: 2px 6px; |
| 147 | border: 1px solid transparent; |
| 148 | border-radius: var(--r-md); |
| 149 | background: transparent; |
| 150 | color: var(--muted); |
| 151 | font-size: 12px; |
| 152 | cursor: pointer; |
| 153 | } |
| 154 | .tab img { width: 24px; height: 24px; object-fit: contain; } |
| 155 | .tab[aria-selected='true'] { border-color: var(--accent); color: var(--text); } |
| 156 | .scroll { flex: 1; overflow-y: auto; min-height: 0; } |
| 157 | .group-head { display: flex; align-items: center; justify-content: space-between; margin: 6px 2px 4px; } |
| 158 | h3 { margin: 0; font-size: 12px; font-weight: 650; color: var(--muted); } |
| 159 | .clone-pack { |
| 160 | border: none; |
| 161 | background: none; |
| 162 | color: var(--accent); |
| 163 | font-size: 12px; |
| 164 | cursor: pointer; |
| 165 | } |
| 166 | .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 4px; } |
| 167 | .cell { |
| 168 | aspect-ratio: 1; |
| 169 | padding: 4px; |
| 170 | border: 1px solid transparent; |
| 171 | border-radius: var(--r-md); |
| 172 | background: transparent; |
| 173 | cursor: pointer; |
| 174 | } |
| 175 | .cell:hover, .cell:focus-visible { background: var(--hover); border-color: var(--border); outline: none; } |
| 176 | .cell img { width: 100%; height: 100%; object-fit: contain; } |
| 177 | .status { color: var(--muted); font-size: 13px; text-align: center; margin: 16px 0 10px; } |
| 178 | .empty { display: flex; flex-direction: column; align-items: center; padding-bottom: 12px; } |
| 179 | </style> |