feat(userbot,api,frontend): file links, media downloads, story hold-pause, drop scheduled dupes
This commit is contained in:
@@ -2,12 +2,17 @@
|
||||
import { Dialog } from "bits-ui";
|
||||
import { untrack } from "svelte";
|
||||
import { type MediaResult, requestMedia } from "$lib/api/client";
|
||||
import { downloadMedia } from "$lib/api/download";
|
||||
import { fetchMedia, getMessageMedia } from "$lib/api/endpoints";
|
||||
import type { ViewerItem } from "$lib/api/media";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
|
||||
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { mediaKindLabel } from "$lib/format/media";
|
||||
import { poster } from "$lib/media/poster";
|
||||
import { shareUi } from "$lib/stores/shares.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -26,8 +31,11 @@
|
||||
|
||||
let kind = $state("");
|
||||
let messageId = $state<number | null>(null);
|
||||
let currentMediaId = $state<number | null>(null);
|
||||
let fileName = $state<string | null>(null);
|
||||
let result = $state<MediaResult | null>(null);
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let token = 0;
|
||||
|
||||
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
|
||||
@@ -39,6 +47,10 @@
|
||||
mime.startsWith("audio/") || kind === "voice" || kind === "audio"
|
||||
);
|
||||
const hasNav = $derived(items.length > 1);
|
||||
const canSave = $derived(
|
||||
currentMediaId !== null && result?.state === "ready"
|
||||
);
|
||||
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
|
||||
|
||||
function revoke() {
|
||||
if (result?.state === "ready") {
|
||||
@@ -52,21 +64,27 @@
|
||||
result = null;
|
||||
kind = item.kind;
|
||||
messageId = item.messageId;
|
||||
currentMediaId = null;
|
||||
fileName = item.fileName;
|
||||
const current = ++token;
|
||||
try {
|
||||
let mediaId = item.mediaId;
|
||||
let downloaded = item.downloaded;
|
||||
let name = item.fileName;
|
||||
if (mediaId === null) {
|
||||
const meta = await getMessageMedia(chatId, item.messageId);
|
||||
mediaId = meta.id;
|
||||
downloaded = meta.downloaded;
|
||||
kind = meta.kind;
|
||||
name = meta.file_name;
|
||||
}
|
||||
const next = downloaded
|
||||
? await requestMedia(mediaId)
|
||||
: ({ state: "not-downloaded" } as MediaResult);
|
||||
if (current === token) {
|
||||
result = next;
|
||||
currentMediaId = mediaId;
|
||||
fileName = name;
|
||||
}
|
||||
} catch {
|
||||
if (current === token) {
|
||||
@@ -85,9 +103,29 @@
|
||||
}
|
||||
try {
|
||||
await fetchMedia(chatId, messageId);
|
||||
toasts.success("Download queued");
|
||||
toasts.success("Скачивание поставлено в очередь");
|
||||
} catch {
|
||||
toasts.error("Failed to queue download");
|
||||
toasts.error("Не удалось поставить в очередь");
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (currentMediaId === null || saving) {
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
await downloadMedia(currentMediaId);
|
||||
} catch {
|
||||
toasts.error("Не удалось скачать файл");
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function share() {
|
||||
if (currentMediaId !== null) {
|
||||
shareUi.share({ kind: "media", mediaId: currentMediaId }, title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +173,43 @@
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="media-overlay" />
|
||||
<Dialog.Content class="media-content">
|
||||
<Dialog.Title class="media-title">{kind || "Media"}</Dialog.Title>
|
||||
<Dialog.Close class="media-close" aria-label="Close">
|
||||
<Icon name="close" size="1.5rem" />
|
||||
</Dialog.Close>
|
||||
<Dialog.Title class="media-title">{title}</Dialog.Title>
|
||||
<div class="media-actions">
|
||||
{#if canSave}
|
||||
<ContextMenu>
|
||||
{#snippet children({ props })}
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
class="media-action"
|
||||
aria-label="Скачать файл"
|
||||
onclick={save}
|
||||
>
|
||||
<Icon name={saving ? "timer" : "download"} size="1.375rem" />
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<ContextMenuItem icon="download" onselect={save}>
|
||||
Скачать файл
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon="allow-share" onselect={share}>
|
||||
Доступ по ссылке
|
||||
</ContextMenuItem>
|
||||
{/snippet}
|
||||
</ContextMenu>
|
||||
<button
|
||||
type="button"
|
||||
class="media-action"
|
||||
aria-label="Доступ по ссылке"
|
||||
onclick={share}
|
||||
>
|
||||
<Icon name="allow-share" size="1.375rem" />
|
||||
</button>
|
||||
{/if}
|
||||
<Dialog.Close class="media-close" aria-label="Закрыть">
|
||||
<Icon name="close" size="1.5rem" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
{#if hasNav}
|
||||
<span class="media-counter">{index + 1} / {items.length}</span>
|
||||
{/if}
|
||||
@@ -162,19 +233,19 @@
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
|
||||
<audio src={result.url} controls></audio>
|
||||
{:else if result?.state === "ready"}
|
||||
<a class="media-download" href={result.url} download>
|
||||
<button class="media-download" type="button" onclick={save}>
|
||||
<Icon name="download" />
|
||||
Download file
|
||||
</a>
|
||||
Скачать файл
|
||||
</button>
|
||||
{:else if result?.state === "not-downloaded"}
|
||||
<div class="media-message">
|
||||
<p>This media has not been downloaded yet.</p>
|
||||
<p>Файл ещё не скачан в архив.</p>
|
||||
<Button variant="primary" fluid onclick={queueFetch}>
|
||||
Fetch media
|
||||
Скачать в архив
|
||||
</Button>
|
||||
</div>
|
||||
{:else if result?.state === "missing"}
|
||||
<p class="media-message">Media not found.</p>
|
||||
<p class="media-message">Файл не найден.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasNav}
|
||||
@@ -222,14 +293,18 @@
|
||||
|
||||
:global(.media-title) {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
top: 1.25rem;
|
||||
left: 1.25rem;
|
||||
|
||||
overflow: hidden;
|
||||
max-width: min(24rem, calc(100% - 14rem));
|
||||
margin: 0;
|
||||
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-white);
|
||||
text-transform: capitalize;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-counter {
|
||||
@@ -244,9 +319,6 @@
|
||||
|
||||
:global(.media-close) {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 1rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -287,8 +359,43 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: 0;
|
||||
border-radius: 1.5rem;
|
||||
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-white);
|
||||
text-decoration: none;
|
||||
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 1rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.media-action {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-white);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.media-nav {
|
||||
|
||||
Reference in New Issue
Block a user