atagen

malice

public
Star0
Watch0
82a50971a50b7d76208cfd61fde7aa3e2665fde6
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/StickerPicker.svelteSvelte171 lines6.0 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
      <span aria-hidden="true">⚙</span>
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
      <p class="status">{withStickers.length === 0 ? 'No stickers yet' : `No stickers match "${query.trim()}"`}</p>
102
    {/each}
103
  </div>
104
</div>
105
106
<style>
107
  .sticker-picker {
108
    display: flex;
109
    flex-direction: column;
110
    gap: 6px;
111
    width: 360px;
112
    height: 420px;
113
    max-width: calc(100vw - 16px);
114
    max-height: calc(100vh - 120px);
115
    padding: 8px;
116
    background: var(--panel);
117
    border: 1px solid var(--border);
118
    border-radius: 10px;
119
    box-shadow: 0 6px 24px rgb(0 0 0 / 0.5);
120
  }
121
  .top { display: flex; gap: 6px; }
122
  .search {
123
    flex: 1;
124
    background: var(--bg-soft);
125
    border: 1px solid var(--border);
126
    border-radius: 8px;
127
    color: var(--text);
128
    padding: 6px 10px;
129
    font-size: 13px;
130
  }
131
  .search:focus-visible { outline: none; border-color: var(--accent); }
132
  .tabs { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 2px; }
133
  .tab {
134
    flex: none;
135
    display: grid;
136
    place-items: center;
137
    min-width: 32px;
138
    height: 32px;
139
    padding: 2px 6px;
140
    border: 1px solid transparent;
141
    border-radius: 8px;
142
    background: transparent;
143
    color: var(--muted);
144
    font-size: 12px;
145
    cursor: pointer;
146
  }
147
  .tab img { width: 24px; height: 24px; object-fit: contain; }
148
  .tab[aria-selected='true'] { border-color: var(--accent); color: var(--text); }
149
  .scroll { flex: 1; overflow-y: auto; min-height: 0; }
150
  .group-head { display: flex; align-items: center; justify-content: space-between; margin: 6px 2px 4px; }
151
  h3 { margin: 0; font-size: 12px; font-weight: 650; color: var(--muted); }
152
  .clone-pack {
153
    border: none;
154
    background: none;
155
    color: var(--accent);
156
    font-size: 12px;
157
    cursor: pointer;
158
  }
159
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 4px; }
160
  .cell {
161
    aspect-ratio: 1;
162
    padding: 4px;
163
    border: 1px solid transparent;
164
    border-radius: 8px;
165
    background: transparent;
166
    cursor: pointer;
167
  }
168
  .cell:hover, .cell:focus-visible { background: var(--hover); border-color: var(--border); outline: none; }
169
  .cell img { width: 100%; height: 100%; object-fit: contain; }
170
  .status { color: var(--muted); font-size: 13px; text-align: center; margin-top: 24px; }
171
</style>