feat(frontend): open sidebar media on click, unbreak long-press menus

This commit is contained in:
hh
2026-08-06 15:17:25 +02:00
parent 12cc7d57e3
commit 004fd56f2c
5 changed files with 156 additions and 39 deletions
@@ -179,7 +179,7 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen} <ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть</ContextMenuItem >Открыть на весь экран</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="recent" icon="recent"
@@ -2,8 +2,16 @@
import { untrack } from "svelte"; import { untrack } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { getChatMedia } from "$lib/api/endpoints"; import { getChatMedia } from "$lib/api/endpoints";
import { type InlineMedia, loadMediaItem, visualKind } from "$lib/api/media"; import {
type InlineMedia,
loadMediaItem,
type ViewerItem,
visualKind,
} from "$lib/api/media";
import type { MediaView } from "$lib/api/types"; import type { MediaView } from "$lib/api/types";
import MediaViewer from "$lib/components/MediaViewer.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte"; import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte";
@@ -12,6 +20,7 @@
import { poster } from "$lib/media/poster"; import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte"; import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte"; import { ui } from "$lib/stores/ui.svelte";
import { isMobile } from "$lib/viewport";
interface Props { interface Props {
chatId: number; chatId: number;
@@ -26,9 +35,20 @@
let items = $state<MediaView[]>([]); let items = $state<MediaView[]>([]);
let loading = $state(false); let loading = $state(false);
let done = $state(false); let done = $state(false);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const previews = $state<Record<number, InlineMedia>>({}); const previews = $state<Record<number, InlineMedia>>({});
let token = 0; let token = 0;
const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({
messageId: item.message_id,
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded,
}))
);
async function loadMore() { async function loadMore() {
if (loading || done) { if (loading || done) {
return; return;
@@ -110,9 +130,17 @@
}; };
}); });
function open(messageId: number) { function open(index: number) {
viewerIndex = index;
viewerOpen = true;
}
function jump(messageId: number) {
ui.requestJump(chatId, messageId); ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`); goto(`/app/${chatId}`);
if (isMobile()) {
ui.closePanel();
}
} }
function preview(item: MediaView): InlineMedia | undefined { function preview(item: MediaView): InlineMedia | undefined {
@@ -128,10 +156,21 @@
{/if} {/if}
{:else if layout === "grid"} {:else if layout === "grid"}
<div class="grid"> <div class="grid">
{#each items as item (item.id)} {#each items as item, index (item.id)}
<button type="button" class="tile" onclick={() => open(item.message_id)}> <ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="tile"
tabindex="0"
onclick={() => open(index)}
>
{#if preview(item)?.state === "ready"} {#if preview(item)?.state === "ready"}
{@const ready = preview(item) as Extract<InlineMedia, { state: "ready" }>} {@const ready = preview(item) as Extract<
InlineMedia,
{ state: "ready" }
>}
{#if visualKind(item.kind) === "video"} {#if visualKind(item.kind) === "video"}
<video <video
src={ready.url} src={ready.url}
@@ -150,13 +189,30 @@
> >
{/if} {/if}
</button> </button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть на весь экран</ContextMenuItem
>
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{/snippet}
</ContextMenu>
{/each} {/each}
</div> </div>
{:else} {:else}
<ul class="list"> <ul class="list">
{#each items as item (item.id)} {#each items as item, index (item.id)}
<li> <li>
<button type="button" onclick={() => open(item.message_id)}> <ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
tabindex="0"
onclick={() => open(index)}
>
<span class="file-icon"><Icon name="document" /></span> <span class="file-icon"><Icon name="document" /></span>
<span class="meta"> <span class="meta">
<span class="name">{mediaKindLabel(item.kind)}</span> <span class="name">{mediaKindLabel(item.kind)}</span>
@@ -168,6 +224,16 @@
</span> </span>
</span> </span>
</button> </button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть</ContextMenuItem
>
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{/snippet}
</ContextMenu>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -184,6 +250,13 @@
</button> </button>
{/if} {/if}
<MediaViewer
bind:open={viewerOpen}
bind:index={viewerIndex}
{chatId}
items={viewerItems}
/>
<style lang="scss"> <style lang="scss">
.center { .center {
display: flex; display: flex;
@@ -8,12 +8,41 @@
} }
const { children, menu }: Props = $props(); const { children, menu }: Props = $props();
type PointerHandler = (event: PointerEvent) => void;
let open = $state(false);
let suppressClick = false;
function triggerProps(props: Record<string, unknown>) {
const onpointerdown = props.onpointerdown as PointerHandler;
const onpointerup = props.onpointerup as PointerHandler;
return {
...props,
onpointerdown(event: PointerEvent) {
suppressClick = false;
event.stopPropagation();
onpointerdown(event);
},
onpointerup(event: PointerEvent) {
suppressClick = open && event.pointerType !== "mouse";
onpointerup(event);
},
onclickcapture(event: MouseEvent) {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
}
},
};
}
</script> </script>
<ContextMenu.Root> <ContextMenu.Root bind:open>
<ContextMenu.Trigger> <ContextMenu.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
{@render children({ props })} {@render children({ props: triggerProps(props) })}
{/snippet} {/snippet}
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Portal>
+8
View File
@@ -153,6 +153,14 @@ html.theme-transition * {
animation: ripple-animation 700ms; animation: ripple-animation 700ms;
} }
@media (pointer: coarse) {
[data-context-menu-trigger] {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
}
.bg-menu-content { .bg-menu-content {
z-index: var(--z-portal-menu); z-index: var(--z-portal-menu);
min-width: 12rem; min-width: 12rem;
+7
View File
@@ -0,0 +1,7 @@
import { browser } from "$app/environment";
const MOBILE_QUERY = "(max-width: 600px)";
export function isMobile(): boolean {
return browser && window.matchMedia(MOBILE_QUERY).matches;
}