atagen

malice

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