click to show lil info panel
This commit is contained in:
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
labelledBy: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean, trigger: HTMLButtonElement) => void;
|
||||||
|
trigger: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
class?: string;
|
||||||
|
triggerClass?: string;
|
||||||
|
panelClass?: string;
|
||||||
|
panelStyle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
labelledBy,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
trigger,
|
||||||
|
children,
|
||||||
|
class: className = "",
|
||||||
|
triggerClass = "",
|
||||||
|
panelClass = "",
|
||||||
|
panelStyle,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let root = $state<HTMLDivElement>();
|
||||||
|
let panel = $state<HTMLDivElement>();
|
||||||
|
let triggerButton = $state<HTMLButtonElement>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
tick().then(() => {
|
||||||
|
if (open) panel?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setOpen(nextOpen: boolean) {
|
||||||
|
if (triggerButton) onOpenChange(nextOpen, triggerButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOnEscape(event: KeyboardEvent) {
|
||||||
|
if (open && event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
triggerButton?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOnOutsidePointer(event: PointerEvent) {
|
||||||
|
if (
|
||||||
|
open &&
|
||||||
|
root &&
|
||||||
|
event.target instanceof Node &&
|
||||||
|
!root.contains(event.target)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOnFocusOut(event: FocusEvent) {
|
||||||
|
if (
|
||||||
|
open &&
|
||||||
|
root &&
|
||||||
|
(!(event.relatedTarget instanceof Node) ||
|
||||||
|
!root.contains(event.relatedTarget))
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOnWindowBlur() {
|
||||||
|
if (open) setOpen(false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onkeydown={closeOnEscape}
|
||||||
|
onpointerdown={closeOnOutsidePointer}
|
||||||
|
onblur={closeOnWindowBlur}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={root}
|
||||||
|
class={`relative grid place-items-center ${className}`}
|
||||||
|
onfocusout={closeOnFocusOut}
|
||||||
|
>
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
bind:this={panel}
|
||||||
|
{id}
|
||||||
|
class={`absolute z-20 focus:outline-none ${panelClass}`}
|
||||||
|
style={panelStyle}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div transition:fly={{ y: 12, duration: 180 }}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
bind:this={triggerButton}
|
||||||
|
type="button"
|
||||||
|
class={`grid cursor-pointer place-items-center border-0 bg-transparent p-0 transition-transform duration-150 hover:-translate-y-0.5 focus-visible:-translate-y-0.5 ${triggerClass}`}
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={id}
|
||||||
|
onclick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
{@render trigger()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -1,33 +1,76 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { startHitboxSync } from "./hitboxes";
|
import Popover from "$lib/components/popover.svelte";
|
||||||
import { liveMetadata } from "$lib/listeners/live-metadata";
|
import { liveMetadata } from "$lib/listeners/live-metadata";
|
||||||
|
import SceneUserPopoverContent from "./scene-user-popover-content.svelte";
|
||||||
|
import { startHitboxSync } from "./hitboxes";
|
||||||
|
|
||||||
|
let selectedUserId: string | null = null;
|
||||||
|
let selectedUserX: number | null = null;
|
||||||
|
|
||||||
onMount(startHitboxSync);
|
onMount(startHitboxSync);
|
||||||
|
|
||||||
|
function setPopoverOpen(
|
||||||
|
userId: string,
|
||||||
|
cursorX: number,
|
||||||
|
open: boolean,
|
||||||
|
trigger: HTMLButtonElement,
|
||||||
|
) {
|
||||||
|
selectedUserId = open ? userId : null;
|
||||||
|
selectedUserX = open
|
||||||
|
? window.innerWidth > 0
|
||||||
|
? trigger.getBoundingClientRect().left / window.innerWidth
|
||||||
|
: cursorX
|
||||||
|
: null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="size-full flex flex-col justify-end">
|
<div class="flex size-full flex-col justify-end">
|
||||||
<div role="banner" class="relative h-8 w-full my-2">
|
<div role="banner" class="relative my-2 h-8 w-full">
|
||||||
{#each Object.entries($liveMetadata.cursorPositions) as [userId, cursor] (userId)}
|
{#each Object.entries($liveMetadata.cursorPositions) as [userId, cursor], index (userId)}
|
||||||
{@const ico = $liveMetadata.foregroundApps.get(userId)?.ico}
|
{@const foregroundApp = $liveMetadata.foregroundApps.get(userId)}
|
||||||
|
{@const popoverId = `scene-user-${index}`}
|
||||||
{#if cursor}
|
{#if cursor}
|
||||||
|
{@const renderedX = selectedUserId === userId ? selectedUserX : cursor.mapped.x}
|
||||||
<div
|
<div
|
||||||
class="scene-hitbox absolute bottom-0 left-0 flex flex-col gap-1 items-center transition-transform ease-linear duration-1000"
|
class="absolute bottom-0 left-0 flex flex-col items-center gap-1 transition-transform duration-1000 ease-linear"
|
||||||
style:transform={`translateX(${cursor.mapped.x * 100}vw)`}
|
style:transform={`translateX(${(renderedX ?? cursor.mapped.x) * 100}vw)`}
|
||||||
>
|
>
|
||||||
{#if ico}
|
{#if foregroundApp?.ico}
|
||||||
<img
|
<img
|
||||||
src={`data:image/png;base64,${ico}`}
|
src={`data:image/png;base64,${foregroundApp.ico}`}
|
||||||
alt=""
|
alt=""
|
||||||
class="size-4 object-contain"
|
class="size-4 object-contain"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<img
|
|
||||||
src="/fa.png"
|
<Popover
|
||||||
alt=""
|
id={popoverId}
|
||||||
class="size-6"
|
label="Show live user information"
|
||||||
style="filter: sepia(1) saturate(200%) hue-rotate(100deg);"
|
labelledBy={`${popoverId}-title`}
|
||||||
/>
|
open={selectedUserId === userId}
|
||||||
|
onOpenChange={(open, trigger) =>
|
||||||
|
setPopoverOpen(userId, cursor.mapped.x, open, trigger)}
|
||||||
|
triggerClass="scene-hitbox"
|
||||||
|
panelClass="scene-hitbox bottom-[calc(100%+0.75rem)] w-68"
|
||||||
|
panelStyle={`left: clamp(calc(0.5rem - ${(renderedX ?? cursor.mapped.x) * 100}vw), -8rem, calc(100vw - ${(renderedX ?? cursor.mapped.x) * 100}vw - 17.5rem))`}
|
||||||
|
>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<img
|
||||||
|
src="/fa.png"
|
||||||
|
alt=""
|
||||||
|
class="size-6 sepia saturate-200 hue-rotate-[100deg]"
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<SceneUserPopoverContent
|
||||||
|
titleId={`${popoverId}-title`}
|
||||||
|
{userId}
|
||||||
|
isLocal={userId === $liveMetadata.localId}
|
||||||
|
{cursor}
|
||||||
|
{foregroundApp}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AppMeta, CursorPositions } from "$lib/bindings";
|
||||||
|
import { friends } from "$lib/listeners/friends";
|
||||||
|
import { profile } from "$lib/listeners/profile";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
titleId: string;
|
||||||
|
userId: string;
|
||||||
|
isLocal: boolean;
|
||||||
|
cursor: CursorPositions;
|
||||||
|
foregroundApp?: AppMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { titleId, userId, isLocal, cursor, foregroundApp }: Props = $props();
|
||||||
|
let username = $derived(
|
||||||
|
$profile?.id === userId
|
||||||
|
? $profile.displayName
|
||||||
|
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
||||||
|
"Unknown user"),
|
||||||
|
);
|
||||||
|
|
||||||
|
function compactId(id: string) {
|
||||||
|
return id.length > 18 ? `${id.slice(0, 9)}…${id.slice(-7)}` : id;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="w-68 overflow-hidden text-neutral-100">
|
||||||
|
<header
|
||||||
|
class="flex w-max items-center ml-2 gap-1 px-2 py-0.5 bg-neutral-800 border border-neutral-500 border-b-0"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p id={titleId} class="truncate text-xs">{username}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid gap-3 p-2 text-sm bg-neutral-900 border border-neutral-500 shadow-lg"
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<h3 class="text-xs font-medium text-neutral-400">Currently enjoying</h3>
|
||||||
|
<div class="mt-1 flex min-w-0 items-center gap-2">
|
||||||
|
{#if foregroundApp?.ico}
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${foregroundApp.ico}`}
|
||||||
|
alt=""
|
||||||
|
class="size-6 shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-xs">
|
||||||
|
{foregroundApp?.local ??
|
||||||
|
foregroundApp?.unlocal ??
|
||||||
|
"Waiting for data"}
|
||||||
|
</p>
|
||||||
|
{#if foregroundApp?.local && foregroundApp.unlocal}
|
||||||
|
<p class="truncate text-xs text-neutral-400">
|
||||||
|
{foregroundApp.unlocal}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
Reference in New Issue
Block a user