atagen

malice

public
Star0
Watch0
6e2fb9d7f322d8990cca482ae98f526fac5a389e
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/StickerPicker.svelteSvelte179 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 { 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>