atagen

malice

public
Star0
Watch0
1b586ca058592ee4ddfa70958d7c0c4ff0c90c69
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/UserCard.svelte

d8624c214c04atagen2026-10-24 08:24 UTC1
<script lang="ts" context="module">
d8624c214c04atagen2026-10-24 08:24 UTC2
  export type UserAction =
d8624c214c04atagen2026-10-24 08:24 UTC3
    | { kind: 'message' | 'mention' | 'unban' | 'mute' | 'unmute' | 'ignore' | 'unignore' }
d8624c214c04atagen2026-10-24 08:24 UTC4
    | { kind: 'kick' | 'ban'; reason: string | null }
d8624c214c04atagen2026-10-24 08:24 UTC5
    | { kind: 'power'; level: number };
d8624c214c04atagen2026-10-24 08:24 UTC6
</script>
d8624c214c04atagen2026-10-24 08:24 UTC7
d8624c214c04atagen2026-10-24 08:24 UTC8
<script lang="ts">
d8624c214c04atagen2026-10-24 08:24 UTC9
  import { onMount, tick } from 'svelte';
d8624c214c04atagen2026-10-24 08:24 UTC10
  import MxcAvatar from './MxcAvatar.svelte';
d8624c214c04atagen2026-10-24 08:24 UTC11
  import { allowed, isMuted, levelOf, roleName, type PowerLevels } from './permissions';
d8624c214c04atagen2026-10-24 08:24 UTC12
  import type { MemberSummary, Person } from './types';
d8624c214c04atagen2026-10-24 08:24 UTC13
d8624c214c04atagen2026-10-24 08:24 UTC14
  export let person: Person;
d8624c214c04atagen2026-10-24 08:24 UTC15
  export let member: MemberSummary | null = null;
d8624c214c04atagen2026-10-24 08:24 UTC16
  export let levels: PowerLevels | null = null;
d8624c214c04atagen2026-10-24 08:24 UTC17
  export let self: string | null = null;
d8624c214c04atagen2026-10-24 08:24 UTC18
  export let x: number;
d8624c214c04atagen2026-10-24 08:24 UTC19
  export let y: number;
d8624c214c04atagen2026-10-24 08:24 UTC20
  export let confirm: 'kick' | 'ban' | null = null;
d8624c214c04atagen2026-10-24 08:24 UTC21
  export let onAction: (action: UserAction) => void;
d8624c214c04atagen2026-10-24 08:24 UTC22
  export let onClose: () => void;
d8624c214c04atagen2026-10-24 08:24 UTC23
d8624c214c04atagen2026-10-24 08:24 UTC24
  const MARGIN = 8;
d8624c214c04atagen2026-10-24 08:24 UTC25
d8624c214c04atagen2026-10-24 08:24 UTC26
  let card: HTMLDivElement;
d8624c214c04atagen2026-10-24 08:24 UTC27
  let left = x;
d8624c214c04atagen2026-10-24 08:24 UTC28
  let top = y;
d8624c214c04atagen2026-10-24 08:24 UTC29
  let reason = '';
d8624c214c04atagen2026-10-24 08:24 UTC30
  let copied = false;
d8624c214c04atagen2026-10-24 08:24 UTC31
d8624c214c04atagen2026-10-24 08:24 UTC32
  $: own = person.user_id === self;
d8624c214c04atagen2026-10-24 08:24 UTC33
  $: level = levels ? levelOf(levels, person.user_id) : null;
d8624c214c04atagen2026-10-24 08:24 UTC34
  $: role = level !== null ? roleName(level) : null;
d8624c214c04atagen2026-10-24 08:24 UTC35
  $: muted = levels ? isMuted(levels, person.user_id) : false;
d8624c214c04atagen2026-10-24 08:24 UTC36
  $: can = levels && self ? allowed(levels, self, person.user_id) : null;
d8624c214c04atagen2026-10-24 08:24 UTC37
  $: banned = member?.membership === 'Ban';
d8624c214c04atagen2026-10-24 08:24 UTC38
  $: powerInput = level !== null && Number.isFinite(level) ? String(level) : '';
d8624c214c04atagen2026-10-24 08:24 UTC39
d8624c214c04atagen2026-10-24 08:24 UTC40
  onMount(async () => {
d8624c214c04atagen2026-10-24 08:24 UTC41
    await tick();
d8624c214c04atagen2026-10-24 08:24 UTC42
    const rect = card.getBoundingClientRect();
d8624c214c04atagen2026-10-24 08:24 UTC43
    left = Math.max(MARGIN, Math.min(x, window.innerWidth - rect.width - MARGIN));
d8624c214c04atagen2026-10-24 08:24 UTC44
    top = Math.max(MARGIN, Math.min(y, window.innerHeight - rect.height - MARGIN));
d8624c214c04atagen2026-10-24 08:24 UTC45
  });
d8624c214c04atagen2026-10-24 08:24 UTC46
d8624c214c04atagen2026-10-24 08:24 UTC47
  function act(action: UserAction): void {
d8624c214c04atagen2026-10-24 08:24 UTC48
    onClose();
d8624c214c04atagen2026-10-24 08:24 UTC49
    onAction(action);
d8624c214c04atagen2026-10-24 08:24 UTC50
  }
d8624c214c04atagen2026-10-24 08:24 UTC51
d8624c214c04atagen2026-10-24 08:24 UTC52
  function setPower(): void {
d8624c214c04atagen2026-10-24 08:24 UTC53
    const value = Number(powerInput);
d8624c214c04atagen2026-10-24 08:24 UTC54
    if (!Number.isInteger(value) || value === level) return;
d8624c214c04atagen2026-10-24 08:24 UTC55
    act({ kind: 'power', level: value });
d8624c214c04atagen2026-10-24 08:24 UTC56
  }
d8624c214c04atagen2026-10-24 08:24 UTC57
d8624c214c04atagen2026-10-24 08:24 UTC58
  async function copyId(): Promise<void> {
d8624c214c04atagen2026-10-24 08:24 UTC59
    try {
d8624c214c04atagen2026-10-24 08:24 UTC60
      await navigator.clipboard.writeText(person.user_id);
d8624c214c04atagen2026-10-24 08:24 UTC61
      copied = true;
d8624c214c04atagen2026-10-24 08:24 UTC62
      window.setTimeout(() => (copied = false), 1500);
d8624c214c04atagen2026-10-24 08:24 UTC63
    } catch {
d8624c214c04atagen2026-10-24 08:24 UTC64
      copied = false;
d8624c214c04atagen2026-10-24 08:24 UTC65
    }
d8624c214c04atagen2026-10-24 08:24 UTC66
  }
d8624c214c04atagen2026-10-24 08:24 UTC67
d8624c214c04atagen2026-10-24 08:24 UTC68
  function outside(ev: PointerEvent): void {
d8624c214c04atagen2026-10-24 08:24 UTC69
    if (!card.contains(ev.target as Node)) onClose();
d8624c214c04atagen2026-10-24 08:24 UTC70
  }
d8624c214c04atagen2026-10-24 08:24 UTC71
d8624c214c04atagen2026-10-24 08:24 UTC72
  function key(ev: KeyboardEvent): void {
d8624c214c04atagen2026-10-24 08:24 UTC73
    if (ev.key !== 'Escape') return;
d8624c214c04atagen2026-10-24 08:24 UTC74
    ev.stopPropagation();
d8624c214c04atagen2026-10-24 08:24 UTC75
    if (confirm) confirm = null;
d8624c214c04atagen2026-10-24 08:24 UTC76
    else onClose();
d8624c214c04atagen2026-10-24 08:24 UTC77
  }
d8624c214c04atagen2026-10-24 08:24 UTC78
</script>
d8624c214c04atagen2026-10-24 08:24 UTC79
d8624c214c04atagen2026-10-24 08:24 UTC80
<svelte:window on:pointerdown|capture={outside} on:keydown|capture={key} on:resize={onClose} />
d8624c214c04atagen2026-10-24 08:24 UTC81
d8624c214c04atagen2026-10-24 08:24 UTC82
<div class="user-card" role="dialog" aria-label={person.name} style={`left:${left}px;top:${top}px`} bind:this={card}>
d8624c214c04atagen2026-10-24 08:24 UTC83
  <div class="identity">
05a48e9e7942atagen2026-10-24 09:32 UTC84
    <MxcAvatar mxc={person.avatar} label={person.name} seed={person.user_id} size={56} />
d8624c214c04atagen2026-10-24 08:24 UTC85
    <div class="names">
d8624c214c04atagen2026-10-24 08:24 UTC86
      <strong class="name">{person.name}</strong>
d8624c214c04atagen2026-10-24 08:24 UTC87
      <button type="button" class="user-id" title="Copy user ID" on:click={() => void copyId()}>
d8624c214c04atagen2026-10-24 08:24 UTC88
        {copied ? 'Copied' : person.user_id}
d8624c214c04atagen2026-10-24 08:24 UTC89
      </button>
d8624c214c04atagen2026-10-24 08:24 UTC90
      <div class="tags">
d8624c214c04atagen2026-10-24 08:24 UTC91
        {#if role}<span class="tag">{role}</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC92
        {#if level !== null && Number.isFinite(level) && level !== 0}<span class="tag">Power {level}</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC93
        {#if muted}<span class="tag warn">Muted</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC94
        {#if banned}<span class="tag danger">Banned</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC95
        {#if member?.ignored}<span class="tag">Ignored</span>{/if}
d8624c214c04atagen2026-10-24 08:24 UTC96
      </div>
d8624c214c04atagen2026-10-24 08:24 UTC97
    </div>
d8624c214c04atagen2026-10-24 08:24 UTC98
  </div>
d8624c214c04atagen2026-10-24 08:24 UTC99
d8624c214c04atagen2026-10-24 08:24 UTC100
  {#if confirm}
d8624c214c04atagen2026-10-24 08:24 UTC101
    <form class="confirm" on:submit|preventDefault={() => confirm && act({ kind: confirm, reason: reason.trim() || null })}>
d8624c214c04atagen2026-10-24 08:24 UTC102
      <!-- svelte-ignore a11y_autofocus -->
d8624c214c04atagen2026-10-24 08:24 UTC103
      <input type="text" placeholder="Reason" bind:value={reason} autofocus />
d8624c214c04atagen2026-10-24 08:24 UTC104
      <div class="row">
d8624c214c04atagen2026-10-24 08:24 UTC105
        <button type="button" class="ghost-btn" on:click={() => (confirm = null)}>Cancel</button>
d8624c214c04atagen2026-10-24 08:24 UTC106
        <button type="submit" class="ghost-btn danger">{confirm === 'kick' ? 'Kick' : 'Ban'} {person.name}</button>
d8624c214c04atagen2026-10-24 08:24 UTC107
      </div>
d8624c214c04atagen2026-10-24 08:24 UTC108
    </form>
d8624c214c04atagen2026-10-24 08:24 UTC109
  {:else}
d8624c214c04atagen2026-10-24 08:24 UTC110
    <div class="row">
d8624c214c04atagen2026-10-24 08:24 UTC111
      {#if !own}
d8624c214c04atagen2026-10-24 08:24 UTC112
        <button type="button" class="ghost-btn" on:click={() => act({ kind: 'message' })}>Message</button>
d8624c214c04atagen2026-10-24 08:24 UTC113
      {/if}
d8624c214c04atagen2026-10-24 08:24 UTC114
      <button type="button" class="ghost-btn" on:click={() => act({ kind: 'mention' })}>Mention</button>
d8624c214c04atagen2026-10-24 08:24 UTC115
    </div>
d8624c214c04atagen2026-10-24 08:24 UTC116
d8624c214c04atagen2026-10-24 08:24 UTC117
    {#if can && (can.kick || can.ban || can.setLevel)}
d8624c214c04atagen2026-10-24 08:24 UTC118
      <div class="row">
d8624c214c04atagen2026-10-24 08:24 UTC119
        {#if can.setLevel && !own}
d8624c214c04atagen2026-10-24 08:24 UTC120
          <button type="button" class="ghost-btn" on:click={() => act({ kind: muted ? 'unmute' : 'mute' })}>
d8624c214c04atagen2026-10-24 08:24 UTC121
            {muted ? 'Unmute' : 'Mute'}
d8624c214c04atagen2026-10-24 08:24 UTC122
          </button>
d8624c214c04atagen2026-10-24 08:24 UTC123
        {/if}
d8624c214c04atagen2026-10-24 08:24 UTC124
        {#if can.kick && !banned && member && member.membership !== 'Leave'}
d8624c214c04atagen2026-10-24 08:24 UTC125
          <button type="button" class="ghost-btn danger" on:click={() => (confirm = 'kick')}>Kick</button>
d8624c214c04atagen2026-10-24 08:24 UTC126
        {/if}
d8624c214c04atagen2026-10-24 08:24 UTC127
        {#if can.ban}
d8624c214c04atagen2026-10-24 08:24 UTC128
          {#if banned}
d8624c214c04atagen2026-10-24 08:24 UTC129
            <button type="button" class="ghost-btn" on:click={() => act({ kind: 'unban' })}>Unban</button>
d8624c214c04atagen2026-10-24 08:24 UTC130
          {:else}
d8624c214c04atagen2026-10-24 08:24 UTC131
            <button type="button" class="ghost-btn danger" on:click={() => (confirm = 'ban')}>Ban</button>
d8624c214c04atagen2026-10-24 08:24 UTC132
          {/if}
d8624c214c04atagen2026-10-24 08:24 UTC133
        {/if}
d8624c214c04atagen2026-10-24 08:24 UTC134
      </div>
d8624c214c04atagen2026-10-24 08:24 UTC135
      {#if can.setLevel}
d8624c214c04atagen2026-10-24 08:24 UTC136
        <form class="row power" on:submit|preventDefault={setPower}>
d8624c214c04atagen2026-10-24 08:24 UTC137
          <label for="power-level">Power level</label>
d8624c214c04atagen2026-10-24 08:24 UTC138
          <input id="power-level" type="number" step="1" max={Number.isFinite(can.maxLevel) ? can.maxLevel : undefined} bind:value={powerInput} />
d8624c214c04atagen2026-10-24 08:24 UTC139
          <button type="submit" class="ghost-btn" disabled={powerInput === '' || Number(powerInput) === level}>Set</button>
d8624c214c04atagen2026-10-24 08:24 UTC140
        </form>
d8624c214c04atagen2026-10-24 08:24 UTC141
      {/if}
d8624c214c04atagen2026-10-24 08:24 UTC142
    {/if}
d8624c214c04atagen2026-10-24 08:24 UTC143
d8624c214c04atagen2026-10-24 08:24 UTC144
    {#if !own}
d8624c214c04atagen2026-10-24 08:24 UTC145
      <div class="row">
d8624c214c04atagen2026-10-24 08:24 UTC146
        <button type="button" class="ghost-btn subtle" on:click={() => act({ kind: member?.ignored ? 'unignore' : 'ignore' })}>
d8624c214c04atagen2026-10-24 08:24 UTC147
          {member?.ignored ? 'Unignore' : 'Ignore'}
d8624c214c04atagen2026-10-24 08:24 UTC148
        </button>
d8624c214c04atagen2026-10-24 08:24 UTC149
      </div>
d8624c214c04atagen2026-10-24 08:24 UTC150
    {/if}
d8624c214c04atagen2026-10-24 08:24 UTC151
  {/if}
d8624c214c04atagen2026-10-24 08:24 UTC152
</div>
d8624c214c04atagen2026-10-24 08:24 UTC153
d8624c214c04atagen2026-10-24 08:24 UTC154
<style>
d8624c214c04atagen2026-10-24 08:24 UTC155
  .user-card {
05a48e9e7942atagen2026-10-24 09:32 UTC156
    animation: pop-in var(--pop);
d8624c214c04atagen2026-10-24 08:24 UTC157
    position: fixed;
d8624c214c04atagen2026-10-24 08:24 UTC158
    z-index: 90;
d8624c214c04atagen2026-10-24 08:24 UTC159
    width: 300px;
d8624c214c04atagen2026-10-24 08:24 UTC160
    max-width: calc(100vw - 16px);
d8624c214c04atagen2026-10-24 08:24 UTC161
    display: flex;
d8624c214c04atagen2026-10-24 08:24 UTC162
    flex-direction: column;
d8624c214c04atagen2026-10-24 08:24 UTC163
    gap: 10px;
d8624c214c04atagen2026-10-24 08:24 UTC164
    padding: 14px;
d8624c214c04atagen2026-10-24 08:24 UTC165
    background: var(--panel-2);
d8624c214c04atagen2026-10-24 08:24 UTC166
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC167
    border-radius: var(--r-md);
d8624c214c04atagen2026-10-24 08:24 UTC168
    box-shadow: 0 12px 32px rgb(0 0 0 / 0.4);
d8624c214c04atagen2026-10-24 08:24 UTC169
  }
d8624c214c04atagen2026-10-24 08:24 UTC170
  .identity { display: flex; gap: 12px; align-items: center; }
d8624c214c04atagen2026-10-24 08:24 UTC171
  .names { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
d8624c214c04atagen2026-10-24 08:24 UTC172
  .name { font-size: 16px; overflow-wrap: anywhere; }
d8624c214c04atagen2026-10-24 08:24 UTC173
  .user-id {
d8624c214c04atagen2026-10-24 08:24 UTC174
    padding: 0;
d8624c214c04atagen2026-10-24 08:24 UTC175
    border: none;
d8624c214c04atagen2026-10-24 08:24 UTC176
    background: none;
d8624c214c04atagen2026-10-24 08:24 UTC177
    color: var(--muted);
d8624c214c04atagen2026-10-24 08:24 UTC178
    font: inherit;
d8624c214c04atagen2026-10-24 08:24 UTC179
    font-size: 12.5px;
d8624c214c04atagen2026-10-24 08:24 UTC180
    text-align: left;
d8624c214c04atagen2026-10-24 08:24 UTC181
    cursor: copy;
d8624c214c04atagen2026-10-24 08:24 UTC182
    overflow-wrap: anywhere;
d8624c214c04atagen2026-10-24 08:24 UTC183
  }
d8624c214c04atagen2026-10-24 08:24 UTC184
  .user-id:hover { color: var(--text); }
d8624c214c04atagen2026-10-24 08:24 UTC185
  .tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
d8624c214c04atagen2026-10-24 08:24 UTC186
  .tag {
d8624c214c04atagen2026-10-24 08:24 UTC187
    font-size: 11px;
d8624c214c04atagen2026-10-24 08:24 UTC188
    padding: 1px 6px;
05a48e9e7942atagen2026-10-24 09:32 UTC189
    border-radius: var(--r-sm);
d8624c214c04atagen2026-10-24 08:24 UTC190
    border: 1px solid var(--border);
d8624c214c04atagen2026-10-24 08:24 UTC191
    color: var(--muted);
d8624c214c04atagen2026-10-24 08:24 UTC192
  }
d8624c214c04atagen2026-10-24 08:24 UTC193
  .tag.warn { color: var(--warn-text); border-color: var(--warn-border); }
d8624c214c04atagen2026-10-24 08:24 UTC194
  .tag.danger { color: var(--danger); border-color: var(--danger-border); }
d8624c214c04atagen2026-10-24 08:24 UTC195
  .row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
d8624c214c04atagen2026-10-24 08:24 UTC196
  .row > :global(.ghost-btn) { flex: 1; }
d8624c214c04atagen2026-10-24 08:24 UTC197
  .power label { font-size: 12.5px; color: var(--muted); }
d8624c214c04atagen2026-10-24 08:24 UTC198
  .power input { width: 80px; flex: 1; }
d8624c214c04atagen2026-10-24 08:24 UTC199
  input {
d8624c214c04atagen2026-10-24 08:24 UTC200
    background: var(--bg-soft);
d8624c214c04atagen2026-10-24 08:24 UTC201
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC202
    border-radius: var(--r-sm);
d8624c214c04atagen2026-10-24 08:24 UTC203
    color: var(--text);
d8624c214c04atagen2026-10-24 08:24 UTC204
    padding: 5px 8px;
d8624c214c04atagen2026-10-24 08:24 UTC205
    font: inherit;
d8624c214c04atagen2026-10-24 08:24 UTC206
    font-size: 13px;
d8624c214c04atagen2026-10-24 08:24 UTC207
  }
d8624c214c04atagen2026-10-24 08:24 UTC208
  .confirm { display: flex; flex-direction: column; gap: 8px; }
d8624c214c04atagen2026-10-24 08:24 UTC209
  .subtle { color: var(--muted); }
d8624c214c04atagen2026-10-24 08:24 UTC210
</style>