atagen

malice

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