atagen

malice

public
Star0
Watch0
445bf199a9cf7524074d760446f7358f805a873d
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/PackManager.svelteSvelte400 lines16.8 KB
1
<script lang="ts">
2
  import { createEventDispatcher } from 'svelte';
3
  import ContextMenu, { type MenuItem } from './ContextMenu.svelte';
4
  import MxcAvatar from './MxcAvatar.svelte';
5
  import EmojiText from './EmojiText.svelte';
6
  import { messageMediaUrl, runCommand, uploadMedia } from './api';
7
  import {
8
    allPacks,
9
    clonePackDestinations,
10
    editableChoices,
11
    hasUsage,
12
    packLabel,
13
    packSlug,
14
    packTarget,
15
    shortcodeFor,
16
    validShortcode
17
  } from './packs';
18
  import type { PackTarget } from './transport';
19
  import type { ImagePack, ImagePacks, PackImage, PackUsage, RoomSummary } from './types';
20
21
  export let packs: ImagePacks;
22
  export let rooms: RoomSummary[];
23
  export let onError: (message: string) => void;
24
25
  const dispatch = createEventDispatcher<{ close: void }>();
26
27
  let selectedId: string | null = 'user';
28
  let creating = false;
29
  let newRoom = '';
30
  let newName = '';
31
  let confirmDelete = false;
32
  let uploading: { done: number; total: number } | null = null;
33
  let menu: { items: MenuItem[]; x: number; y: number } | null = null;
34
  let fileInput: HTMLInputElement;
35
  let avatarInput: HTMLInputElement;
36
37
  const roomName = (roomId: string) => rooms.find((r) => r.room_id === roomId)?.name ?? roomId;
38
39
  $: list = allPacks(packs);
40
  $: userPack = list.find((p) => p.source === 'User') ?? null;
41
  $: roomPacks = list.filter((p) => p.source !== 'User' && p.editable);
42
  $: otherPacks = list.filter((p) => p.source !== 'User' && !p.editable);
43
  $: packRooms = rooms.filter((r) => r.can_edit_packs && r.membership === 'Joined');
44
  $: selected = selectedId === 'user' ? userPack : (list.find((p) => p.id === selectedId) ?? null);
45
  $: target = selectedId === 'user' ? ({ kind: 'user' } as PackTarget) : selected ? packTarget(selected) : null;
46
  $: editable = selectedId === 'user' || (selected?.editable ?? false);
47
  $: if (selectedId) confirmDelete = false;
48
49
  async function run(work: () => Promise<unknown>, failure: string): Promise<boolean> {
50
    try {
51
      await work();
52
      return true;
53
    } catch (e) {
54
      onError(e instanceof Error ? e.message : failure);
55
      return false;
56
    }
57
  }
58
59
  async function createPack(): Promise<void> {
60
    const name = newName.trim();
61
    if (!newRoom || !name) return;
62
    const state_key = packSlug(name);
63
    const ok = await run(
64
      () => runCommand({ op: 'create_room_pack', room_id: newRoom, state_key, display_name: name }),
65
      'Could not create the pack'
66
    );
67
    if (!ok) return;
68
    creating = false;
69
    newName = '';
70
    selectedId = `room:${newRoom}:${state_key}`;
71
  }
72
73
  async function rename(event: Event): Promise<void> {
74
    const name = (event.currentTarget as HTMLInputElement).value.trim();
75
    if (!target || !name || name === (selected?.display_name ?? '')) return;
76
    await run(() => runCommand({ op: 'set_pack_meta', target: target!, display_name: name }), 'Could not rename the pack');
77
  }
78
79
  async function changeAvatar(): Promise<void> {
80
    const file = avatarInput.files?.[0];
81
    avatarInput.value = '';
82
    if (!file || !target) return;
83
    const t = target;
84
    await run(async () => {
85
      const { mxc } = await uploadMedia(file);
86
      await runCommand({ op: 'set_pack_meta', target: t, avatar: mxc });
87
    }, 'Could not change the pack avatar');
88
  }
89
90
  async function addImages(): Promise<void> {
91
    const files = [...(fileInput.files ?? [])].filter((f) => f.type.startsWith('image/'));
92
    fileInput.value = '';
93
    if (!files.length || !target) return;
94
    const t = target;
95
    const taken = new Set(selected?.images.map((i) => i.shortcode) ?? []);
96
    uploading = { done: 0, total: files.length };
97
    for (const file of files) {
98
      const shortcode = shortcodeFor(file.name, taken);
99
      await run(async () => {
100
        const { mxc } = await uploadMedia(file);
101
        const size = await imageSize(file);
102
        const info = { mimetype: file.type || null, size_bytes: file.size, width: size?.width ?? null, height: size?.height ?? null };
103
        await runCommand({ op: 'put_pack_image', target: t, shortcode, mxc, body: null, usage: null, info });
104
      }, `Could not add ${file.name}`);
105
      uploading = { done: uploading.done + 1, total: files.length };
106
    }
107
    uploading = null;
108
  }
109
110
  async function imageSize(file: File): Promise<{ width: number; height: number } | null> {
111
    try {
112
      const bitmap = await createImageBitmap(file);
113
      const size = { width: bitmap.width, height: bitmap.height };
114
      bitmap.close();
115
      return size;
116
    } catch {
117
      return null;
118
    }
119
  }
120
121
  async function renameImage(image: PackImage, event: Event): Promise<void> {
122
    const input = event.currentTarget as HTMLInputElement;
123
    const to = input.value.trim();
124
    if (!target || to === image.shortcode) return;
125
    if (!validShortcode(to) || selected?.images.some((i) => i.shortcode === to)) {
126
      input.value = image.shortcode;
127
      onError(`Can't rename to "${to}"`);
128
      return;
129
    }
130
    await run(() => runCommand({ op: 'rename_pack_image', target: target!, from: image.shortcode, to }), 'Could not rename');
131
  }
132
133
  async function toggleUsage(image: PackImage, usage: PackUsage): Promise<void> {
134
    if (!target) return;
135
    const current: PackUsage[] = image.usage.length ? image.usage : ['Emoticon', 'Sticker'];
136
    const next = current.includes(usage) ? current.filter((u) => u !== usage) : [...current, usage];
137
    if (next.length === 0) return;
138
    await run(
139
      () =>
140
        runCommand({
141
          op: 'put_pack_image',
142
          target: target!,
143
          shortcode: image.shortcode,
144
          mxc: image.media_id,
145
          body: image.body,
146
          usage: next
147
        }),
148
      'Could not change how the image is used'
149
    );
150
  }
151
152
  async function removeImage(image: PackImage): Promise<void> {
153
    if (!target) return;
154
    await run(() => runCommand({ op: 'remove_pack_image', target: target!, shortcode: image.shortcode }), 'Could not remove');
155
  }
156
157
  async function setEverywhere(enabled: boolean): Promise<void> {
158
    if (!selected || selected.source === 'User') return;
159
    const { room_id, state_key } = selected.source.Room;
160
    await run(() => runCommand({ op: 'set_pack_enabled', room_id, state_key, enabled }), 'Could not change the pack');
161
  }
162
163
  async function deletePack(): Promise<void> {
164
    if (!target) return;
165
    const ok = await run(() => runCommand({ op: 'delete_pack', target: target! }), 'Could not delete the pack');
166
    if (ok) selectedId = 'user';
167
  }
168
169
  function cloneImageMenu(pack: ImagePack, image: PackImage, ev: MouseEvent): void {
170
    menu = {
171
      x: ev.clientX,
172
      y: ev.clientY,
173
      items: editableChoices(packs, roomName).map((choice) => ({
174
        label: `Clone to ${choice.label}`,
175
        action: () =>
176
          void run(
177
            () => runCommand({ op: 'clone_pack_image', from_pack: pack.id, shortcode: image.shortcode, to: choice.target }),
178
            'Could not clone the image'
179
          )
180
      }))
181
    };
182
  }
183
184
  function clonePackMenu(pack: ImagePack, ev: MouseEvent): void {
185
    menu = {
186
      x: ev.clientX,
187
      y: ev.clientY,
188
      items: clonePackDestinations(pack, packRooms, roomName).map((d) => ({
189
        label: d.label,
190
        action: () =>
191
          void run(async () => {
192
            const res = (await runCommand({ op: 'clone_pack', from_pack: pack.id, to: d.target, display_name: d.display_name })) as {
193
              skipped?: string[];
194
            };
195
            if (res?.skipped?.length) onError(`Couldn't copy ${res.skipped.map((s) => `:${s}:`).join(', ')}`);
196
          }, 'Could not clone the pack')
197
      }))
198
    };
199
  }
200
</script>
201
202
<div class="modal packs" role="dialog" aria-modal="true" aria-label="Sticker and emoji packs">
203
  <div class="modal-tabs" role="tablist" aria-label="Packs">
204
    <button type="button" role="tab" aria-selected="true" class="active">Packs</button>
205
    <button type="button" class="icon-btn modal-x" aria-label="Close packs" on:click={() => dispatch('close')}>
206
      <span aria-hidden="true">✕</span>
207
    </button>
208
  </div>
209
210
  <div class="layout">
211
    <nav class="pack-list" aria-label="Packs">
212
      <button type="button" class="pack-item" class:active={selectedId === 'user'} on:click={() => (selectedId = 'user')}>
213
        <MxcAvatar mxc={userPack?.avatar ?? null} label="Your pack" size={28} shape="rounded" />
214
        <span><EmojiText text={userPack?.display_name ?? 'Your pack'} /></span>
215
      </button>
216
      {#if roomPacks.length}
217
        <h4>Room packs</h4>
218
        {#each roomPacks as pack (pack.id)}
219
          <button type="button" class="pack-item" class:active={selectedId === pack.id} on:click={() => (selectedId = pack.id)}>
220
            <MxcAvatar mxc={pack.avatar} label={packLabel(pack, roomName)} size={28} shape="rounded" />
221
            <span><EmojiText text={packLabel(pack, roomName)} /></span>
222
          </button>
223
        {/each}
224
      {/if}
225
      {#if otherPacks.length}
226
        <h4>Other packs</h4>
227
        {#each otherPacks as pack (pack.id)}
228
          <button type="button" class="pack-item" class:active={selectedId === pack.id} on:click={() => (selectedId = pack.id)}>
229
            <MxcAvatar mxc={pack.avatar} label={packLabel(pack, roomName)} size={28} shape="rounded" />
230
            <span><EmojiText text={packLabel(pack, roomName)} /></span>
231
          </button>
232
        {/each}
233
      {/if}
234
      {#if packRooms.length}
235
        {#if creating}
236
          <form class="new-pack" on:submit|preventDefault={() => void createPack()}>
237
            <select bind:value={newRoom} aria-label="Room">
238
              <option value="" disabled>Room</option>
239
              {#each packRooms as room (room.room_id)}
240
                <option value={room.room_id}>{room.name}</option>
241
              {/each}
242
            </select>
243
            <input type="text" placeholder="Pack name" bind:value={newName} aria-label="Pack name" />
244
            <div class="row">
245
              <button type="button" class="ghost-btn" on:click={() => (creating = false)}>Cancel</button>
246
              <button type="submit" class="primary-btn" disabled={!newRoom || !newName.trim()}>Create</button>
247
            </div>
248
          </form>
249
        {:else}
250
          <button type="button" class="ghost-btn new-btn" on:click={() => (creating = true)}>New room pack</button>
251
        {/if}
252
      {/if}
253
    </nav>
254
255
    <section class="editor" aria-label="Pack">
256
      {#if editable && target}
257
        <div class="meta">
258
          <button type="button" class="avatar-btn" title="Change pack avatar" on:click={() => avatarInput.click()}>
259
            <MxcAvatar mxc={selected?.avatar ?? null} label={selected?.display_name ?? 'Pack'} size={48} shape="rounded" />
260
          </button>
261
          <input
262
            class="name"
263
            type="text"
264
            value={selected?.display_name ?? ''}
265
            placeholder={selectedId === 'user' ? 'Your pack' : 'Pack name'}
266
            aria-label="Pack name"
267
            on:change={(e) => void rename(e)}
268
          />
269
          <input type="file" accept="image/*" class="visually-hidden" bind:this={avatarInput} on:change={() => void changeAvatar()} />
270
        </div>
271
        <div class="row">
272
          {#if selected && selected.source !== 'User'}
273
            <label class="check">
274
              <input type="checkbox" checked={selected.enabled_everywhere} on:change={(e) => void setEverywhere(e.currentTarget.checked)} />
275
              Use everywhere
276
            </label>
277
          {/if}
278
          <span class="spacer"></span>
279
          <button type="button" class="primary-btn" disabled={uploading !== null} on:click={() => fileInput.click()}>
280
            {uploading ? `Uploading ${uploading.done}/${uploading.total}` : 'Add images'}
281
          </button>
282
          <input type="file" accept="image/*" multiple class="visually-hidden" bind:this={fileInput} on:change={() => void addImages()} />
283
          {#if selected}
284
            {#if confirmDelete}
285
              <button type="button" class="ghost-btn" on:click={() => (confirmDelete = false)}>Keep</button>
286
              <button type="button" class="ghost-btn danger" on:click={() => void deletePack()}>Delete pack</button>
287
            {:else}
288
              <button type="button" class="ghost-btn danger" on:click={() => (confirmDelete = true)}>Delete</button>
289
            {/if}
290
          {/if}
291
        </div>
292
        <div class="images">
293
          {#each selected?.images ?? [] as image (image.shortcode)}
294
            <div class="image">
295
              <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" />
296
              <input type="text" value={image.shortcode} aria-label="Shortcode" on:change={(e) => void renameImage(image, e)} />
297
              <div class="usage">
298
                <button type="button" aria-pressed={hasUsage(image, 'Emoticon')} title="Emoji" on:click={() => void toggleUsage(image, 'Emoticon')}>☺</button>
299
                <button type="button" aria-pressed={hasUsage(image, 'Sticker')} title="Sticker" on:click={() => void toggleUsage(image, 'Sticker')}>▣</button>
300
                <button type="button" class="remove" title="Remove" aria-label={`Remove ${image.shortcode}`} on:click={() => void removeImage(image)}>✕</button>
301
              </div>
302
            </div>
303
          {:else}
304
            <p class="empty">No images yet</p>
305
          {/each}
306
        </div>
307
      {:else if selected}
308
        <div class="meta">
309
          <MxcAvatar mxc={selected.avatar} label={packLabel(selected, roomName)} size={48} shape="rounded" />
310
          <strong class="name"><EmojiText text={packLabel(selected, roomName)} /></strong>
311
          <span class="spacer"></span>
312
          <button type="button" class="primary-btn" on:click={(ev) => selected && clonePackMenu(selected, ev)}>Clone pack</button>
313
        </div>
314
        <div class="images">
315
          {#each selected.images as image (image.shortcode)}
316
            <div class="image">
317
              <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt={image.shortcode} loading="lazy" />
318
              <span class="code">{image.shortcode}</span>
319
              <button type="button" class="ghost-btn" on:click={(ev) => selected && cloneImageMenu(selected, image, ev)}>Clone</button>
320
            </div>
321
          {/each}
322
        </div>
323
      {/if}
324
    </section>
325
  </div>
326
</div>
327
328
{#if menu}
329
  <ContextMenu items={menu.items} x={menu.x} y={menu.y} onClose={() => (menu = null)} />
330
{/if}
331
332
<style>
333
  .packs { width: min(820px, calc(100vw - 32px)); max-height: calc(100vh - 64px); display: flex; flex-direction: column; }
334
  .layout { display: flex; gap: 14px; min-height: 0; flex: 1; }
335
  .pack-list { flex: none; width: 210px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
336
  h4 { margin: 10px 6px 4px; font-size: 12px; color: var(--muted); font-weight: 650; }
337
  .pack-item {
338
    display: flex;
339
    align-items: center;
340
    gap: 8px;
341
    padding: 6px 8px;
342
    border: none;
343
    border-radius: var(--r-md);
344
    background: transparent;
345
    color: var(--text);
346
    font: inherit;
347
    font-size: 13.5px;
348
    text-align: left;
349
    cursor: pointer;
350
  }
351
  .pack-item span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
352
  .pack-item:hover { background: var(--hover); }
353
  .pack-item.active { background: color-mix(in srgb, var(--accent) 20%, transparent); }
354
  .new-btn { margin-top: 8px; }
355
  .new-pack { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
356
  .editor { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
357
  .meta { display: flex; align-items: center; gap: 10px; }
358
  .avatar-btn { padding: 0; border: none; background: none; cursor: pointer; border-radius: var(--r-md); }
359
  .name { flex: 1; font-size: 15px; }
360
  .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
361
  .spacer { flex: 1; }
362
  .check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
363
  input[type='text'], select {
364
    background: var(--bg-soft);
365
    border: 1px solid var(--border);
366
    border-radius: var(--r-sm);
367
    color: var(--text);
368
    padding: 6px 8px;
369
    font: inherit;
370
    font-size: 13px;
371
    min-width: 0;
372
  }
373
  .images { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
374
  .image {
375
    display: flex;
376
    flex-direction: column;
377
    gap: 4px;
378
    padding: 6px;
379
    border: 1px solid var(--border);
380
    border-radius: var(--r-md);
381
    background: var(--bg-soft);
382
  }
383
  .image img { width: 100%; aspect-ratio: 1; object-fit: contain; }
384
  .image input { width: 100%; font-size: 12px; padding: 3px 6px; }
385
  .code { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
386
  .usage { display: flex; gap: 4px; }
387
  .usage button {
388
    flex: 1;
389
    padding: 2px 0;
390
    border: 1px solid var(--border);
391
    border-radius: var(--r-sm);
392
    background: transparent;
393
    color: var(--muted);
394
    cursor: pointer;
395
  }
396
  .usage button[aria-pressed='true'] { color: var(--accent); border-color: var(--accent); }
397
  .usage .remove { flex: none; padding: 2px 6px; }
398
  .usage .remove:hover { color: var(--danger); border-color: var(--danger); }
399
  .empty { color: var(--muted); font-size: 13px; }
400
</style>