atagen

malice

public
Star0
Watch0
64a05dba3514e3e9a77cd07df9039f1309e6023b
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/UserCard.svelteSvelte211 lines7.4 KB
1
<script lang="ts" context="module">
2
  export type UserAction =
3
    | { kind: 'message' | 'mention' | 'unban' | 'mute' | 'unmute' | 'ignore' | 'unignore' }
4
    | { kind: 'kick' | 'ban'; reason: string | null }
5
    | { kind: 'power'; level: number };
6
</script>
7
8
<script lang="ts">
9
  import { onMount, tick } from 'svelte';
10
  import MxcAvatar from './MxcAvatar.svelte';
11
  import EmojiText from './EmojiText.svelte';
12
  import { allowed, isMuted, levelOf, roleName, type PowerLevels } from './permissions';
13
  import type { MemberSummary, Person } from './types';
14
15
  export let person: Person;
16
  export let member: MemberSummary | null = null;
17
  export let levels: PowerLevels | null = null;
18
  export let self: string | null = null;
19
  export let x: number;
20
  export let y: number;
21
  export let confirm: 'kick' | 'ban' | null = null;
22
  export let onAction: (action: UserAction) => void;
23
  export let onClose: () => void;
24
25
  const MARGIN = 8;
26
27
  let card: HTMLDivElement;
28
  let left = x;
29
  let top = y;
30
  let reason = '';
31
  let copied = false;
32
33
  $: own = person.user_id === self;
34
  $: level = levels ? levelOf(levels, person.user_id) : null;
35
  $: role = level !== null ? roleName(level) : null;
36
  $: muted = levels ? isMuted(levels, person.user_id) : false;
37
  $: can = levels && self ? allowed(levels, self, person.user_id) : null;
38
  $: banned = member?.membership === 'Ban';
39
  $: powerInput = level !== null && Number.isFinite(level) ? String(level) : '';
40
41
  onMount(async () => {
42
    await tick();
43
    const rect = card.getBoundingClientRect();
44
    left = Math.max(MARGIN, Math.min(x, window.innerWidth - rect.width - MARGIN));
45
    top = Math.max(MARGIN, Math.min(y, window.innerHeight - rect.height - MARGIN));
46
  });
47
48
  function act(action: UserAction): void {
49
    onClose();
50
    onAction(action);
51
  }
52
53
  function setPower(): void {
54
    const value = Number(powerInput);
55
    if (!Number.isInteger(value) || value === level) return;
56
    act({ kind: 'power', level: value });
57
  }
58
59
  async function copyId(): Promise<void> {
60
    try {
61
      await navigator.clipboard.writeText(person.user_id);
62
      copied = true;
63
      window.setTimeout(() => (copied = false), 1500);
64
    } catch {
65
      copied = false;
66
    }
67
  }
68
69
  function outside(ev: PointerEvent): void {
70
    if (!card.contains(ev.target as Node)) onClose();
71
  }
72
73
  function key(ev: KeyboardEvent): void {
74
    if (ev.key !== 'Escape') return;
75
    ev.stopPropagation();
76
    if (confirm) confirm = null;
77
    else onClose();
78
  }
79
</script>
80
81
<svelte:window on:pointerdown|capture={outside} on:keydown|capture={key} on:resize={onClose} />
82
83
<div class="user-card" role="dialog" aria-label={person.name} style={`left:${left}px;top:${top}px`} bind:this={card}>
84
  <div class="identity">
85
    <MxcAvatar mxc={person.avatar} label={person.name} seed={person.user_id} size={56} />
86
    <div class="names">
87
      <strong class="name"><EmojiText text={person.name} /></strong>
88
      <button type="button" class="user-id" title="Copy user ID" on:click={() => void copyId()}>
89
        {copied ? 'Copied' : person.user_id}
90
      </button>
91
      <div class="tags">
92
        {#if role}<span class="tag">{role}</span>{/if}
93
        {#if level !== null && Number.isFinite(level) && level !== 0}<span class="tag">Power {level}</span>{/if}
94
        {#if muted}<span class="tag warn">Muted</span>{/if}
95
        {#if banned}<span class="tag danger">Banned</span>{/if}
96
        {#if member?.ignored}<span class="tag">Ignored</span>{/if}
97
      </div>
98
    </div>
99
  </div>
100
101
  {#if confirm}
102
    <form class="confirm" on:submit|preventDefault={() => confirm && act({ kind: confirm, reason: reason.trim() || null })}>
103
      <!-- svelte-ignore a11y_autofocus -->
104
      <input type="text" placeholder="Reason" bind:value={reason} autofocus />
105
      <div class="row">
106
        <button type="button" class="ghost-btn" on:click={() => (confirm = null)}>Cancel</button>
107
        <button type="submit" class="ghost-btn danger">{confirm === 'kick' ? 'Kick' : 'Ban'} <EmojiText text={person.name} /></button>
108
      </div>
109
    </form>
110
  {:else}
111
    <div class="row">
112
      {#if !own}
113
        <button type="button" class="ghost-btn" on:click={() => act({ kind: 'message' })}>Message</button>
114
      {/if}
115
      <button type="button" class="ghost-btn" on:click={() => act({ kind: 'mention' })}>Mention</button>
116
    </div>
117
118
    {#if can && (can.kick || can.ban || can.setLevel)}
119
      <div class="row">
120
        {#if can.setLevel && !own}
121
          <button type="button" class="ghost-btn" on:click={() => act({ kind: muted ? 'unmute' : 'mute' })}>
122
            {muted ? 'Unmute' : 'Mute'}
123
          </button>
124
        {/if}
125
        {#if can.kick && !banned && member && member.membership !== 'Leave'}
126
          <button type="button" class="ghost-btn danger" on:click={() => (confirm = 'kick')}>Kick</button>
127
        {/if}
128
        {#if can.ban}
129
          {#if banned}
130
            <button type="button" class="ghost-btn" on:click={() => act({ kind: 'unban' })}>Unban</button>
131
          {:else}
132
            <button type="button" class="ghost-btn danger" on:click={() => (confirm = 'ban')}>Ban</button>
133
          {/if}
134
        {/if}
135
      </div>
136
      {#if can.setLevel}
137
        <form class="row power" on:submit|preventDefault={setPower}>
138
          <label for="power-level">Power level</label>
139
          <input id="power-level" type="number" step="1" max={Number.isFinite(can.maxLevel) ? can.maxLevel : undefined} bind:value={powerInput} />
140
          <button type="submit" class="ghost-btn" disabled={powerInput === '' || Number(powerInput) === level}>Set</button>
141
        </form>
142
      {/if}
143
    {/if}
144
145
    {#if !own}
146
      <div class="row">
147
        <button type="button" class="ghost-btn subtle" on:click={() => act({ kind: member?.ignored ? 'unignore' : 'ignore' })}>
148
          {member?.ignored ? 'Unignore' : 'Ignore'}
149
        </button>
150
      </div>
151
    {/if}
152
  {/if}
153
</div>
154
155
<style>
156
  .user-card {
157
    animation: pop-in var(--pop);
158
    position: fixed;
159
    z-index: 90;
160
    width: 300px;
161
    max-width: calc(100vw - 16px);
162
    display: flex;
163
    flex-direction: column;
164
    gap: 10px;
165
    padding: 14px;
166
    background: var(--panel-2);
167
    border: 1px solid var(--border);
168
    border-radius: var(--r-md);
169
    box-shadow: 0 12px 32px rgb(0 0 0 / 0.4);
170
  }
171
  .identity { display: flex; gap: 12px; align-items: center; }
172
  .names { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
173
  .name { font-size: 16px; overflow-wrap: anywhere; }
174
  .user-id {
175
    padding: 0;
176
    border: none;
177
    background: none;
178
    color: var(--muted);
179
    font: inherit;
180
    font-size: 12.5px;
181
    text-align: left;
182
    cursor: copy;
183
    overflow-wrap: anywhere;
184
  }
185
  .user-id:hover { color: var(--text); }
186
  .tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
187
  .tag {
188
    font-size: 11px;
189
    padding: 1px 6px;
190
    border-radius: var(--r-sm);
191
    border: 1px solid var(--border);
192
    color: var(--muted);
193
  }
194
  .tag.warn { color: var(--warn-text); border-color: var(--warn-border); }
195
  .tag.danger { color: var(--danger); border-color: var(--danger-border); }
196
  .row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
197
  .row > :global(.ghost-btn) { flex: 1; }
198
  .power label { font-size: 12.5px; color: var(--muted); }
199
  .power input { width: 80px; flex: 1; }
200
  input {
201
    background: var(--bg-soft);
202
    border: 1px solid var(--border);
203
    border-radius: var(--r-sm);
204
    color: var(--text);
205
    padding: 5px 8px;
206
    font: inherit;
207
    font-size: 13px;
208
  }
209
  .confirm { display: flex; flex-direction: column; gap: 8px; }
210
  .subtle { color: var(--muted); }
211
</style>