atagen

malice

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