feat(userbot,api,frontend): file links, media downloads, story hold-pause, drop scheduled dupes

This commit is contained in:
hh
2026-08-13 03:17:36 +02:00
parent 683b9a31a3
commit 3e08698b62
38 changed files with 2889 additions and 134 deletions
+61
View File
@@ -0,0 +1,61 @@
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const FILENAME_STAR = /filename\*=UTF-8''([^;]+)/i;
const FILENAME_PLAIN = /filename="?([^";]+)"?/i;
function nameFromHeader(header: string | null, fallback: string): string {
if (!header) {
return fallback;
}
const encoded = FILENAME_STAR.exec(header);
if (encoded) {
return decodeURIComponent(encoded[1]);
}
const plain = FILENAME_PLAIN.exec(header);
return plain ? plain[1] : fallback;
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function save(path: string, fallback: string): Promise<void> {
const response = await fetch(`${BASE}${path}`, {
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
});
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const fileName = nameFromHeader(
response.headers.get("content-disposition"),
fallback
);
saveBlob(await response.blob(), fileName);
}
export function downloadMedia(mediaId: number): Promise<void> {
return save(`/media/${mediaId}?download=true`, `media_${mediaId}`);
}
export function downloadMediaVersion(versionId: number): Promise<void> {
return save(
`/media/version/${versionId}?download=true`,
`media_${versionId}`
);
}
export function downloadStory(peerId: number, storyId: number): Promise<void> {
return save(
`/stories/${peerId}/${storyId}/media?download=true&account_id=${accounts.selectedId}`,
`story_${peerId}_${storyId}`
);
}
+5 -1
View File
@@ -20,6 +20,7 @@ export type InlineMedia =
export interface ViewerItem {
downloaded: boolean;
fileName: string | null;
kind: string;
mediaId: number | null;
messageId: number;
@@ -30,13 +31,16 @@ export function viewerItemsFrom(
media: MediaRef[]
): ViewerItem[] {
if (media.length === 0) {
return [{ messageId, mediaId: null, kind: "", downloaded: false }];
return [
{ messageId, mediaId: null, kind: "", downloaded: false, fileName: null },
];
}
return media.map((item) => ({
messageId: item.message_id,
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded,
fileName: item.file_name,
}));
}
+119
View File
@@ -0,0 +1,119 @@
import { request } from "$lib/api/client";
import type { FileShare, FileShareHit, ShareSubject } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
export interface ShareSettings {
expiresInSeconds: number | null;
keepExpiry?: boolean;
maxDownloads: number | null;
}
function subjectQuery(subject: ShareSubject): Record<string, number | string> {
const query: Record<string, number | string> = { kind: subject.kind };
if (subject.mediaId !== undefined) {
query.media_id = subject.mediaId;
}
if (subject.versionId !== undefined) {
query.version_id = subject.versionId;
}
if (subject.peerId !== undefined) {
query.peer_id = subject.peerId;
}
if (subject.storyId !== undefined) {
query.story_id = subject.storyId;
}
return query;
}
export function listShares(activeOnly = false): Promise<FileShare[]> {
return request<FileShare[]>("/shares", {
account: true,
query: { active_only: activeOnly, limit: 200 },
});
}
export function lookupShare(subject: ShareSubject): Promise<FileShare | null> {
return request<FileShare | null>("/shares/lookup", {
account: true,
query: subjectQuery(subject),
});
}
export function createShare(
subject: ShareSubject,
settings: ShareSettings
): Promise<FileShare> {
return request<FileShare>("/shares", {
method: "POST",
body: {
account_id: accounts.selectedId,
kind: subject.kind,
media_id: subject.mediaId ?? null,
version_id: subject.versionId ?? null,
peer_id: subject.peerId ?? null,
story_id: subject.storyId ?? null,
expires_in_seconds: settings.expiresInSeconds,
max_downloads: settings.maxDownloads,
},
});
}
export function updateShare(
id: number,
settings: ShareSettings
): Promise<FileShare> {
return request<FileShare>(`/shares/${id}`, {
method: "PATCH",
body: {
expires_in_seconds: settings.expiresInSeconds,
keep_expiry: settings.keepExpiry ?? false,
max_downloads: settings.maxDownloads,
},
});
}
export function revokeShare(id: number): Promise<FileShare> {
return request<FileShare>(`/shares/${id}/revoke`, { method: "POST" });
}
export function reissueShare(id: number): Promise<FileShare> {
return request<FileShare>(`/shares/${id}/reissue`, { method: "POST" });
}
export function deleteShare(id: number): Promise<void> {
return request<void>(`/shares/${id}`, { method: "DELETE" });
}
export function listShareHits(id: number): Promise<FileShareHit[]> {
return request<FileShareHit[]>(`/shares/${id}/hits`, {
query: { limit: 50 },
});
}
const TRAILING_SLASH = /\/$/;
export function shareUrl(share: FileShare): string {
const origin =
typeof window === "undefined"
? ""
: window.location.origin.replace(TRAILING_SLASH, "");
return `${origin}/f/${share.token}`;
}
export function shareState(
share: FileShare
): "active" | "revoked" | "expired" | "exhausted" {
if (share.revoked_at) {
return "revoked";
}
if (share.expires_at && Date.parse(share.expires_at) <= Date.now()) {
return "expired";
}
if (
share.max_downloads !== null &&
share.download_count >= share.max_downloads
) {
return "exhausted";
}
return "active";
}
+39
View File
@@ -92,6 +92,7 @@ export interface ForwardView {
export interface MediaRef {
downloaded: boolean;
duration: number | null;
file_name: string | null;
file_size: number | null;
height: number | null;
id: number | null;
@@ -242,6 +243,7 @@ export interface MediaView {
created_at: string;
downloaded: boolean;
extracted_text: string | null;
file_name: string | null;
file_size: number | null;
id: number;
kind: string;
@@ -506,3 +508,40 @@ export type LiveEvent =
| LiveDeleteEvent
| LivePresenceEvent
| LiveReceiptEvent;
export interface FileShare {
account_id: number;
chat_id: number | null;
created_at: string;
download_count: number;
expires_at: string | null;
file_name: string;
file_size: number | null;
id: number;
kind: string;
last_download_at: string | null;
max_downloads: number | null;
message_id: number | null;
mime: string | null;
peer_id: number | null;
revoked_at: string | null;
story_id: number | null;
title: string | null;
token: string;
}
export interface FileShareHit {
counted: boolean;
ip: string | null;
method: string;
ts: string;
user_agent: string | null;
}
export interface ShareSubject {
kind: "media" | "media_version" | "story";
mediaId?: number;
peerId?: number;
storyId?: number;
versionId?: number;
}
+125 -18
View File
@@ -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 {
@@ -1,5 +1,6 @@
<script lang="ts">
import { visible } from "$lib/actions/visible";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia } from "$lib/api/endpoints";
import {
type InlineMedia,
@@ -15,7 +16,9 @@
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";
import { ui } from "$lib/stores/ui.svelte";
@@ -33,6 +36,7 @@
let loaded = $state(false);
let media = $state<InlineMedia | null>(null);
let queuing = $state(false);
let saving = $state(false);
const ready = $derived(media?.state === "ready" ? media : null);
const kind = $derived(ready?.kind ?? "");
@@ -56,6 +60,7 @@
const label = $derived(
media && media.state !== "missing" ? media.kind : "media"
);
const storedId = $derived(ready?.mediaId ?? null);
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
@@ -86,14 +91,37 @@
queuing = true;
try {
await fetchMedia(message.chat_id, message.message_id);
toasts.success("Download queued");
toasts.success("Скачивание поставлено в очередь");
poll();
} catch {
toasts.error("Failed to queue download");
toasts.error("Не удалось поставить в очередь");
} finally {
queuing = false;
}
}
async function save() {
if (storedId === null || saving) {
return;
}
saving = true;
try {
await downloadMedia(storedId);
} catch {
toasts.error("Не удалось скачать файл");
} finally {
saving = false;
}
}
function share() {
if (storedId !== null) {
shareUi.share(
{ kind: "media", mediaId: storedId },
mediaKindLabel(kind) ?? "Файл"
);
}
}
</script>
<ContextMenu>
@@ -102,7 +130,7 @@
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Self-destruct media</span>
<span>Самоуничтожающееся медиа</span>
</button>
{:else if !loaded}
<div class="media-skeleton"><Spinner /></div>
@@ -161,18 +189,18 @@
{:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Video" : "Photo"}</span>
<small>{queuing ? "Queued" : "Tap to download"}</small>
<span>{vk === "video" ? "Видео" : "Фото"}</span>
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small>
</button>
{:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "Queued" : `Download ${label}`}</span>
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Media</span>
<span>Медиа</span>
</button>
{/if}
</div>
@@ -186,7 +214,18 @@
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
>
<ContextMenuItem icon="download" onselect={queue}>Скачать</ContextMenuItem>
{#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}>
Скачать в архив
</ContextMenuItem>
{:else}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
@@ -6,6 +6,7 @@
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
import ProfilePanel from "$lib/components/profile/ProfilePanel.svelte";
import ChatSearchPanel from "$lib/components/search/ChatSearchPanel.svelte";
import SharesPanel from "$lib/components/shares/SharesPanel.svelte";
import CallbacksPanel from "$lib/components/social/CallbacksPanel.svelte";
import LinksPanel from "$lib/components/social/LinksPanel.svelte";
import ReactionsPanel from "$lib/components/social/ReactionsPanel.svelte";
@@ -31,6 +32,7 @@
policy: "Политика захвата",
watches: "Отслеживания",
alerts: "Алерты",
shares: "Файлы по ссылке",
};
</script>
@@ -74,6 +76,8 @@
<AlertsPanel />
{:else if ui.rightPanel === "annotations"}
<AnnotationsPanel />
{:else if ui.rightPanel === "shares"}
<SharesPanel />
{/if}
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { downloadMedia } from "$lib/api/download";
import { getChatMedia } from "$lib/api/endpoints";
import {
type InlineMedia,
@@ -19,6 +20,8 @@
import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
import { isMobile } from "$lib/viewport";
@@ -46,9 +49,26 @@
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded,
fileName: item.file_name,
}))
);
function displayName(item: MediaView): string {
return item.file_name ?? mediaKindLabel(item.kind) ?? "Файл";
}
async function save(item: MediaView) {
try {
await downloadMedia(item.id);
} catch {
toasts.error("Не удалось скачать файл");
}
}
function share(item: MediaView) {
shareUi.share({ kind: "media", mediaId: item.id }, displayName(item));
}
async function loadMore() {
if (loading || done) {
return;
@@ -113,6 +133,7 @@
kind: item.kind,
downloaded: item.downloaded,
mime: item.mime,
file_name: item.file_name,
file_size: item.file_size,
ttl_seconds: item.ttl_seconds,
duration: null,
@@ -197,6 +218,14 @@
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem
>
{/if}
{/snippet}
</ContextMenu>
{/each}
@@ -215,7 +244,7 @@
>
<span class="file-icon"><Icon name="document" /></span>
<span class="meta">
<span class="name">{mediaKindLabel(item.kind)}</span>
<span class="name">{displayName(item)}</span>
<span class="sub">
{formatListDate(item.created_at)}
{#if item.file_size}
@@ -232,6 +261,14 @@
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem
>
{/if}
{/snippet}
</ContextMenu>
</li>
@@ -52,6 +52,11 @@
label="Сторис"
onclick={() => ui.openPanel("stories-all")}
/>
<SettingsItem
icon="allow-share"
label="Файлы по ссылке"
onclick={() => ui.openPanel("shares")}
/>
<SettingsItem
icon="eye"
label="Отслеживания"
@@ -0,0 +1,472 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { ApiError } from "$lib/api/client";
import {
createShare,
lookupShare,
revokeShare,
type ShareSettings,
shareUrl,
updateShare,
} from "$lib/api/shares";
import type { FileShare, ShareSubject } from "$lib/api/types";
import ShareLinkField from "$lib/components/shares/ShareLinkField.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import QrCode from "$lib/components/ui/QrCode.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatShareLimits } from "$lib/format/shares";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
label?: string;
onchange?: (share: FileShare | null) => void;
open: boolean;
subject: ShareSubject | null;
}
let {
open = $bindable(),
subject,
label = "файл",
onchange,
}: Props = $props();
const HOUR = 3600;
const DAY = 24 * HOUR;
const KEEP = -1;
const expiryChoices = [
{ label: "Бессрочно", value: null },
{ label: "1 час", value: HOUR },
{ label: "24 часа", value: DAY },
{ label: "7 дней", value: 7 * DAY },
{ label: "30 дней", value: 30 * DAY },
];
const keepChoice = { label: "Как есть", value: KEEP };
const limitChoices = [
{ label: "Без лимита", value: null },
{ label: "1", value: 1 },
{ label: "5", value: 5 },
{ label: "25", value: 25 },
{ label: "100", value: 100 },
];
let share = $state<FileShare | null>(null);
let loading = $state(false);
let busy = $state(false);
let expiresIn = $state<number | null>(null);
let maxDownloads = $state<number | null>(null);
let showQr = $state(false);
let token = 0;
const settings = $derived<ShareSettings>({
expiresInSeconds: expiresIn === KEEP ? null : expiresIn,
keepExpiry: expiresIn === KEEP,
maxDownloads,
});
const liveExpiryChoices = $derived(
share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices
);
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
}
function adopt(next: FileShare | null) {
share = next;
onchange?.(next);
}
async function load(target: ShareSubject) {
loading = true;
share = null;
showQr = false;
expiresIn = null;
maxDownloads = null;
const current = ++token;
try {
const found = await lookupShare(target);
if (current === token) {
share = found;
maxDownloads = found?.max_downloads ?? null;
expiresIn = found?.expires_at ? KEEP : null;
}
} catch (error) {
if (current === token) {
fail(error, "Не удалось проверить ссылку");
open = false;
}
} finally {
if (current === token) {
loading = false;
}
}
}
async function publish() {
if (!subject || busy) {
return;
}
busy = true;
try {
adopt(await createShare(subject, settings));
toasts.success("Доступ по ссылке открыт");
} catch (error) {
fail(error, "Не удалось открыть доступ");
} finally {
busy = false;
}
}
async function applyLimits() {
if (!share || busy) {
return;
}
busy = true;
try {
adopt(await updateShare(share.id, settings));
toasts.success("Настройки ссылки обновлены");
} catch (error) {
fail(error, "Не удалось обновить ссылку");
} finally {
busy = false;
}
}
async function revoke() {
if (!share || busy) {
return;
}
busy = true;
try {
await revokeShare(share.id);
adopt(null);
toasts.success("Доступ отозван");
open = false;
} catch (error) {
fail(error, "Не удалось отозвать доступ");
} finally {
busy = false;
}
}
$effect(() => {
const target = subject;
const isOpen = open;
untrack(() => {
if (isOpen && target) {
load(target);
}
});
});
</script>
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Доступ по ссылке</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if loading}
<div class="center"><Spinner /></div>
{:else if share}
<div class="subject live">
<span class="badge"
><Icon name="allow-share" size="1.125rem" /></span
>
<div class="subject-text">
<strong>{share.file_name}</strong>
<span>{formatShareLimits(share)}</span>
</div>
</div>
<ShareLinkField url={shareUrl(share)} />
<div class="qr-row">
<button
type="button"
class="qr-toggle"
onclick={() => {
showQr = !showQr;
}}
>
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
{showQr ? "Скрыть QR-код" : "Показать QR-код"}
</button>
</div>
{#if showQr}
<div class="qr-slot">
<QrCode value={shareUrl(share)} label="QR-код ссылки на файл" />
</div>
{/if}
<p class="hint raw">
Ссылка отдаёт файл как есть —
<code>curl -O {shareUrl(share)}</code>
скачает его без авторизации. Просмотры превью в Telegram не
списывают скачивания.
</p>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={maxDownloads === choice.value}
onclick={() => {
maxDownloads = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<div class="segments">
{#each liveExpiryChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={expiresIn === choice.value}
onclick={() => {
expiresIn = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
{:else}
<div class="subject">
<span class="badge muted"
><Icon name="lock" size="1.125rem" /></span
>
<div class="subject-text">
<strong>{label}</strong>
<span>Сейчас доступен только вам</span>
</div>
</div>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<div class="segments">
{#each expiryChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={expiresIn === choice.value}
onclick={() => {
expiresIn = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={maxDownloads === choice.value}
onclick={() => {
maxDownloads = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
<p class="hint">
Файл откроется всем, у кого есть ссылка. Отозвать можно в любой
момент.
</p>
{/if}
</div>
{#if !loading}
<div class="dialog-actions">
{#if share}
<Button variant="danger" pill loading={busy} onclick={revoke}>
Отозвать
</Button>
<Button pill loading={busy} onclick={applyLimits}>Сохранить</Button>
{:else}
<Button pill fluid loading={busy} onclick={publish}>
Открыть доступ
</Button>
{/if}
</div>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.subject {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.badge {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-green);
&.muted {
color: var(--color-text-secondary);
background-color: var(--color-background-secondary);
}
}
.subject-text {
display: flex;
flex-direction: column;
min-width: 0;
strong {
overflow: hidden;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
span {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
}
.qr-row {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.qr-toggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 0;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-primary);
cursor: pointer;
background: transparent;
}
.qr-slot {
--qr-bg: transparent;
--qr-fg: var(--color-text);
width: min(11rem, 60%);
margin: 0.25rem auto 0;
}
.hint {
margin: 1rem 0 0;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--color-text-secondary);
&.raw {
margin-top: 0.875rem;
}
code {
overflow-wrap: anywhere;
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
background-color: var(--color-background-secondary);
}
}
.choices {
margin: 1.25rem 0 0;
padding: 0;
border: 0;
}
legend {
padding: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.segments {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.segment {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 1rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text);
cursor: pointer;
background-color: transparent;
transition:
background-color 0.15s,
border-color 0.15s,
color 0.15s;
&.selected {
border-color: transparent;
color: var(--color-white);
background-color: var(--color-primary);
}
}
</style>
@@ -0,0 +1,81 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
url: string;
}
const { url }: Props = $props();
const COPIED_MS = 1600;
let copied = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
async function copy() {
try {
await navigator.clipboard.writeText(url);
} catch {
toasts.error("Не удалось скопировать ссылку");
return;
}
copied = true;
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
copied = false;
}, COPIED_MS);
}
</script>
<button type="button" class="link-field" class:copied onclick={copy}>
<span class="url">{url}</span>
<span class="action">
<Icon name={copied ? "check" : "copy"} size="1.125rem" />
</span>
</button>
<style lang="scss">
.link-field {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.625rem 0.625rem 0.875rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
font-family: inherit;
text-align: start;
cursor: pointer;
background-color: var(--color-background-secondary);
transition:
border-color 0.15s,
color 0.15s;
&.copied {
border-color: var(--color-green);
color: var(--color-green);
}
}
.url {
overflow-wrap: anywhere;
flex: 1;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.8125rem;
line-height: 1.35;
color: inherit;
}
.action {
display: flex;
flex-shrink: 0;
color: inherit;
}
</style>
@@ -0,0 +1,472 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
deleteShare,
listShares,
reissueShare,
revokeShare,
shareState,
shareUrl,
} from "$lib/api/shares";
import type { FileShare } from "$lib/api/types";
import ShareLinkField from "$lib/components/shares/ShareLinkField.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 Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
import { formatBytes } from "$lib/format/media";
import { formatDownloads, formatExpiry, plural } from "$lib/format/shares";
import { accounts } from "$lib/stores/accounts.svelte";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
type Filter = "active" | "all";
const stateLabels: Record<string, string> = {
active: "Открыт",
revoked: "Отозван",
expired: "Истёк",
exhausted: "Лимит исчерпан",
};
let items = $state<FileShare[]>([]);
let loading = $state(false);
let filter = $state<Filter>("active");
let expanded = $state<number | null>(null);
let token = 0;
const visible = $derived(
filter === "active"
? items.filter((item) => shareState(item) === "active")
: items
);
const activeCount = $derived(
items.filter((item) => shareState(item) === "active").length
);
async function load() {
const current = ++token;
loading = true;
try {
const rows = await listShares();
if (current === token) {
items = rows;
}
} catch {
if (current === token) {
toasts.error("Не удалось загрузить список ссылок");
}
} finally {
if (current === token) {
loading = false;
}
}
}
function replace(next: FileShare) {
items = items.map((item) => (item.id === next.id ? next : item));
}
function jump(share: FileShare) {
if (share.chat_id === null || share.message_id === null) {
if (share.peer_id !== null) {
goto(`/app/${share.peer_id}`);
ui.openPanel("stories");
}
return;
}
goto(`/app/${share.chat_id}`);
ui.requestJump(share.chat_id, share.message_id);
ui.closePanel();
}
async function copy(share: FileShare) {
await navigator.clipboard.writeText(shareUrl(share));
toasts.success("Ссылка скопирована");
}
async function revoke(share: FileShare) {
try {
replace(await revokeShare(share.id));
toasts.success("Доступ отозван");
} catch {
toasts.error("Не удалось отозвать доступ");
}
}
async function reissue(share: FileShare) {
try {
replace(await reissueShare(share.id));
toasts.success("Выдана новая ссылка");
} catch {
toasts.error("Не удалось перевыпустить ссылку");
}
}
async function forget(share: FileShare) {
try {
await deleteShare(share.id);
items = items.filter((item) => item.id !== share.id);
} catch {
toasts.error("Не удалось удалить запись");
}
}
let loadedKey = "";
$effect(() => {
const key = `${accounts.selectedId}:${shareUi.revision}`;
if (accounts.selectedId !== null && key !== loadedKey) {
loadedKey = key;
load();
}
});
</script>
<div class="toolbar">
<div class="tabs">
<button
type="button"
class="tab"
class:selected={filter === "active"}
onclick={() => {
filter = "active";
}}
>
Открытые{activeCount ? ` · ${activeCount}` : ""}
</button>
<button
type="button"
class="tab"
class:selected={filter === "all"}
onclick={() => {
filter = "all";
}}
>
Все
</button>
</div>
<button
type="button"
class="reload"
aria-label="Обновить"
onclick={() => load()}
>
<Icon name="reload" size="1.125rem" />
</button>
</div>
{#if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if visible.length === 0}
<EmptyState
title="Ничего не открыто"
description="Откройте доступ к файлу из меню медиа"
/>
{:else}
<ul class="list">
{#each visible as share (share.id)}
{@const state = shareState(share)}
<li class="row" class:inactive={state !== "active"}>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="head"
onclick={() => {
expanded = expanded === share.id ? null : share.id;
}}
>
<span class="glyph" class:muted={state !== "active"}>
<Icon
name={state === "active" ? "allow-share" : "no-share"}
size="1.125rem"
/>
</span>
<span class="text">
<span class="name">{share.file_name}</span>
<span class="sub">
{stateLabels[state]}
· {formatDownloads(share)}
{#if share.file_size}
· {formatBytes(share.file_size)}
{/if}
</span>
<span class="sub">
{share.title ?? "Без чата"}
· {formatExpiry(share)}
</span>
</span>
<span class="chevron" class:open={expanded === share.id}>
<Icon name="down" size="1rem" />
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="copy" onselect={() => copy(share)}>
Копировать ссылку
</ContextMenuItem>
{#if share.message_id !== null || share.peer_id !== null}
<ContextMenuItem icon="reply" onselect={() => jump(share)}>
Перейти к сообщению
</ContextMenuItem>
{/if}
{#if state === "active"}
<ContextMenuItem
icon="link-broken"
onselect={() => revoke(share)}
>
Отозвать
</ContextMenuItem>
{:else}
<ContextMenuItem icon="replace" onselect={() => reissue(share)}>
Выдать новую ссылку
</ContextMenuItem>
<ContextMenuItem icon="delete" onselect={() => forget(share)}>
Убрать из списка
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
{#if expanded === share.id}
<div class="details">
<ShareLinkField url={shareUrl(share)} />
{#if share.last_download_at}
<p class="last">
Последнее скачивание: {formatFull(share.last_download_at)}
</p>
{/if}
<div class="actions">
{#if share.message_id !== null || share.peer_id !== null}
<button type="button" class="pill" onclick={() => jump(share)}>
<Icon name="reply" size="1rem" />К сообщению
</button>
{/if}
{#if state === "active"}
<button
type="button"
class="pill danger"
onclick={() => revoke(share)}
>
<Icon name="link-broken" size="1rem" />Отозвать
</button>
{:else}
<button
type="button"
class="pill"
onclick={() => reissue(share)}
>
<Icon name="replace" size="1rem" />Новая ссылка
</button>
{/if}
</div>
</div>
{/if}
</li>
{/each}
</ul>
<p class="footnote">
{visible.length}
{plural(visible.length, "ссылка", "ссылки", "ссылок")}
· превью-краулеры не списывают скачивания
</p>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.tabs {
display: flex;
gap: 0.375rem;
}
.tab {
padding: 0.3125rem 0.75rem;
border: 0;
border-radius: 1rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text-secondary);
cursor: pointer;
background-color: var(--color-background-secondary);
&.selected {
color: var(--color-white);
background-color: var(--color-primary);
}
}
.reload {
display: flex;
padding: 0.375rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
list-style: none;
}
.row {
border-radius: 0.75rem;
&.inactive .name {
color: var(--color-text-secondary);
text-decoration: line-through;
}
}
.head {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.5rem;
border: 0;
border-radius: 0.75rem;
font-family: inherit;
text-align: start;
cursor: pointer;
background: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-green);
&.muted {
color: var(--color-text-secondary);
background-color: var(--color-background-secondary);
}
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
overflow: hidden;
font-size: 0.75rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
display: flex;
flex-shrink: 0;
color: var(--color-text-secondary);
transition: transform 0.15s;
&.open {
transform: rotate(180deg);
}
}
.details {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0 0.5rem 0.75rem;
}
.last {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0.6875rem;
border: 1px solid var(--color-borders);
border-radius: 1rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text);
cursor: pointer;
background: transparent;
&.danger {
color: var(--color-error);
}
}
.footnote {
margin: 0;
padding: 0 1rem 1rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -8,6 +8,7 @@
} from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import StoryTile from "$lib/components/stories/StoryTile.svelte";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Button from "$lib/components/ui/Button.svelte";
@@ -211,34 +212,11 @@
{#if expanded[group.peerId]}
<div class="grid">
{#each group.stories as item, index (item.story_id)}
<button
type="button"
class="tile"
class:expired={item.deleted}
onclick={() => openViewer(group, index)}
>
{#if previews[item.story_id]}
{#if item.media_kind === "video"}
<video
src={previews[item.story_id]}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={previews[item.story_id]} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if item.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{item.views}
</span>
{/if}
</button>
<StoryTile
story={item}
preview={previews[item.story_id]}
onopen={() => openViewer(group, index)}
/>
{/each}
</div>
{/if}
@@ -4,6 +4,7 @@
import { enqueueStoriesBackfill, getStories } from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import StoryTile from "$lib/components/stories/StoryTile.svelte";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
import Button from "$lib/components/ui/Button.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
@@ -161,39 +162,11 @@
{:else}
<div class="grid">
{#each items as item, index (item.story_id)}
<button
type="button"
class="tile"
class:expired={item.deleted}
onclick={() => openViewer(index)}
>
{#if previews[item.story_id]}
{#if item.media_kind === "video"}
<video
src={previews[item.story_id]}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={previews[item.story_id]} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if item.pinned}
<span class="badge pin"
><Icon name="story-priority" size="0.875rem" /></span
>
{/if}
{#if item.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{item.views}
</span>
{/if}
</button>
<StoryTile
story={item}
preview={previews[item.story_id]}
onopen={() => openViewer(index)}
/>
{/each}
</div>
@@ -0,0 +1,171 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { downloadStory } from "$lib/api/download";
import type { StoryView } from "$lib/api/types";
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 { formatFull } from "$lib/format/datetime";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
onopen: () => void;
preview: string | null | undefined;
story: StoryView;
}
const { story, preview, onopen }: Props = $props();
const label = $derived(
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
);
async function save() {
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
}
}
function share() {
shareUi.share(
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
label
);
}
function openAuthor() {
goto(`/app/${story.peer_id}`);
ui.openPanel("profile");
}
</script>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="tile"
class:expired={story.deleted}
onclick={onopen}
>
{#if preview}
{#if story.media_kind === "video"}
<video
src={preview}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={preview} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if story.pinned}
<span class="badge pin">
<Icon name="story-priority" size="0.875rem" />
</span>
{/if}
{#if story.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{story.views}
</span>
{/if}
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}>
Открыть
</ContextMenuItem>
{#if story.downloaded}
<ContextMenuItem icon="download" onselect={save}>
Скачать
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/if}
<ContextMenuItem icon="info" onselect={openAuthor}>
Профиль автора
</ContextMenuItem>
{/snippet}
</ContextMenu>
<style lang="scss">
.tile {
position: relative;
aspect-ratio: 9 / 16;
padding: 0;
border: 0;
cursor: pointer;
background-color: var(--color-background-secondary);
&.expired {
opacity: 0.55;
}
}
.tile img,
.tile video {
width: 100%;
height: 100%;
object-fit: cover;
}
.play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
text-shadow: 0 0 4px rgb(0 0 0 / 50%);
}
.ph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--color-text-secondary);
}
.badge {
position: absolute;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.125rem 0.25rem;
border-radius: 0.5rem;
font-size: 0.6875rem;
color: var(--color-white);
background-color: rgb(0 0 0 / 45%);
}
.pin {
top: 0.25rem;
left: 0.25rem;
}
.views {
bottom: 0.25rem;
left: 0.25rem;
}
</style>
@@ -1,11 +1,16 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { downloadStory } from "$lib/api/download";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
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 { formatFull } from "$lib/format/datetime";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
index: number;
@@ -22,16 +27,23 @@
}: Props = $props();
const PHOTO_SECONDS = 6;
const HOLD_MS = 180;
let url = $state<string | null>(null);
let loading = $state(false);
let ready = $state(false);
let videoProgress = $state(0);
let muted = $state(true);
let held = $state(false);
let saving = $state(false);
let video = $state<HTMLVideoElement | null>(null);
let token = 0;
let holdTimer: ReturnType<typeof setTimeout> | null = null;
let suppressTap = false;
const story = $derived(items[index] ?? null);
const isVideo = $derived(story?.media_kind === "video");
const canSave = $derived(Boolean(story?.downloaded));
function step(delta: number) {
const next = index + delta;
@@ -49,6 +61,37 @@
step(1);
}
function startHold() {
suppressTap = false;
if (holdTimer) {
clearTimeout(holdTimer);
}
holdTimer = setTimeout(() => {
held = true;
video?.pause();
}, HOLD_MS);
}
function releaseHold() {
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
if (held) {
suppressTap = true;
held = false;
video?.play().catch(() => undefined);
}
}
function onTap(delta: number) {
if (suppressTap) {
suppressTap = false;
return;
}
step(delta);
}
async function load(item: StoryView) {
loading = true;
ready = false;
@@ -63,12 +106,36 @@
}
function onVideoTime(event: Event) {
const video = event.currentTarget as HTMLVideoElement;
if (video.duration > 0) {
videoProgress = video.currentTime / video.duration;
const element = event.currentTarget as HTMLVideoElement;
if (element.duration > 0) {
videoProgress = element.currentTime / element.duration;
}
}
async function save() {
if (!story || saving) {
return;
}
saving = true;
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
} finally {
saving = false;
}
}
function share() {
if (!story) {
return;
}
shareUi.share(
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
);
}
function onkeydown(event: KeyboardEvent) {
if (!open) {
return;
@@ -77,6 +144,14 @@
step(-1);
} else if (event.key === "ArrowRight") {
step(1);
} else if (event.key === " " && !event.repeat) {
event.preventDefault();
held = !held;
if (held) {
video?.pause();
} else {
video?.play().catch(() => undefined);
}
}
}
@@ -97,17 +172,18 @@
untrack(() => {
url = null;
ready = false;
held = false;
});
}
});
</script>
<svelte:window {onkeydown} />
<svelte:window {onkeydown} onpointerup={releaseHold} />
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="story-overlay" />
<Dialog.Content class="story-content">
<Dialog.Content class="story-content {held ? 'held' : ''}">
<Dialog.Title class="story-a11y-title">Сторис</Dialog.Title>
<div class="bars">
{#each items as item, i (item.story_id)}
@@ -119,6 +195,7 @@
{#if ready && !isVideo}
<div
class="fill anim"
class:paused={held}
style="animation-duration: {PHOTO_SECONDS}s"
onanimationend={advance}
></div>
@@ -146,6 +223,29 @@
<Icon name={muted ? "speaker-muted-story" : "speaker-story"} />
</button>
{/if}
{#if canSave}
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="round"
aria-label="Скачать сторис"
onclick={save}
>
<Icon name={saving ? "timer" : "download"} />
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/snippet}
</ContextMenu>
{/if}
<Dialog.Close class="round" aria-label="Закрыть">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
@@ -158,6 +258,7 @@
{:else if url && isVideo}
<!-- biome-ignore lint/a11y/useMediaCaption: archived story has no captions -->
<video
bind:this={video}
class="media"
src={url}
autoplay
@@ -200,14 +301,22 @@
type="button"
class="tap prev"
aria-label="Назад"
onclick={() => step(-1)}
onpointerdown={startHold}
onpointercancel={releaseHold}
onclick={() => onTap(-1)}
></button>
<button
type="button"
class="tap next"
aria-label="Вперёд"
onclick={() => step(1)}
onpointerdown={startHold}
onpointercancel={releaseHold}
onclick={() => onTap(1)}
></button>
{#if held}
<span class="hold-hint">Пауза</span>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -268,6 +377,10 @@
&.anim {
animation: story-progress linear forwards;
}
&.paused {
animation-play-state: paused;
}
}
@keyframes story-progress {
@@ -376,6 +489,9 @@
cursor: pointer;
background: transparent;
touch-action: none;
user-select: none;
-webkit-touch-callout: none;
&.prev {
left: 0;
@@ -386,4 +502,44 @@
width: 65%;
}
}
.hold-hint {
position: absolute;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
color: var(--color-white);
letter-spacing: 0.04em;
background-color: rgba(255, 255, 255, 0.16);
backdrop-filter: blur(6px);
animation: hold-in 0.18s ease;
pointer-events: none;
z-index: 3;
}
@keyframes hold-in {
from {
opacity: 0;
transform: translate(-50%, 0.375rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
:global(.story-content.held) .bars,
:global(.story-content.held) .story-head,
:global(.story-content.held) .caption,
:global(.story-content.held) .view-count {
opacity: 0.25;
transition: opacity 0.2s;
}
</style>
+15 -15
View File
@@ -1,27 +1,27 @@
const MEDIA_KIND_LABELS: Record<string, string> = {
photo: "Photo",
video: "Video",
photo: "Фото",
video: "Видео",
animation: "GIF",
gif: "GIF",
voice: "Voice message",
audio: "Audio",
video_note: "Video message",
sticker: "Sticker",
document: "File",
contact: "Contact",
location: "Location",
venue: "Location",
poll: "Poll",
dice: "Dice",
game: "Game",
story: "Story",
voice: "Голосовое",
audio: "Аудио",
video_note: "Кружок",
sticker: "Стикер",
document: "Файл",
contact: "Контакт",
location: "Геопозиция",
venue: "Геопозиция",
poll: "Опрос",
dice: "Кубик",
game: "Игра",
story: "Сторис",
};
export function mediaKindLabel(kind: string | null): string | null {
if (!kind) {
return null;
}
return MEDIA_KIND_LABELS[kind] ?? "Media";
return MEDIA_KIND_LABELS[kind] ?? "Медиа";
}
const BYTE_UNITS = ["B", "KB", "MB", "GB"];
+55
View File
@@ -0,0 +1,55 @@
import type { FileShare } from "$lib/api/types";
import { formatFull } from "$lib/format/datetime";
const TEEN_START = 11;
const TEEN_END = 14;
const FEW_END = 4;
export function plural(
count: number,
one: string,
few: string,
many: string
): string {
const tail = count % 100;
if (tail >= TEEN_START && tail <= TEEN_END) {
return many;
}
const last = count % 10;
if (last === 1) {
return one;
}
if (last >= 2 && last <= FEW_END) {
return few;
}
return many;
}
export function formatDownloads(share: FileShare): string {
const word = plural(
share.download_count,
"скачивание",
"скачивания",
"скачиваний"
);
if (share.max_downloads === null) {
return `${share.download_count} ${word}`;
}
return `${share.download_count} из ${share.max_downloads}`;
}
export function formatExpiry(share: FileShare): string {
if (share.revoked_at) {
return "отозвана";
}
if (!share.expires_at) {
return "бессрочно";
}
const expires = Date.parse(share.expires_at);
const prefix = expires <= Date.now() ? "истекла" : "до";
return `${prefix} ${formatFull(share.expires_at)}`;
}
export function formatShareLimits(share: FileShare): string {
return `${formatDownloads(share)} · ${formatExpiry(share)}`;
}
+36
View File
@@ -0,0 +1,36 @@
import type { ShareSubject } from "$lib/api/types";
function createShareUi() {
let open = $state(false);
let subject = $state<ShareSubject | null>(null);
let label = $state("файл");
let revision = $state(0);
return {
get open() {
return open;
},
set open(value: boolean) {
open = value;
},
get subject() {
return subject;
},
get label() {
return label;
},
get revision() {
return revision;
},
share(next: ShareSubject, name = "файл") {
subject = next;
label = name;
open = true;
},
touch() {
revision += 1;
},
};
}
export const shareUi = createShareUi();
+2 -1
View File
@@ -12,7 +12,8 @@ export type RightPanel =
| "stories-all"
| "policy"
| "watches"
| "alerts";
| "alerts"
| "shares";
export type LeftView = "main" | "settings";
+9
View File
@@ -8,12 +8,14 @@
import SearchInput from "$lib/components/search/SearchInput.svelte";
import SearchResults from "$lib/components/search/SearchResults.svelte";
import Settings from "$lib/components/settings/Settings.svelte";
import ShareDialog from "$lib/components/shares/ShareDialog.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
import { events } from "$lib/stores/events.svelte";
import { search } from "$lib/stores/search.svelte";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
@@ -98,6 +100,13 @@
{/if}
</div>
<ShareDialog
bind:open={shareUi.open}
subject={shareUi.subject}
label={shareUi.label}
onchange={() => shareUi.touch()}
/>
<style lang="scss">
#Main {
display: grid;
+1
View File
@@ -21,6 +21,7 @@ export default defineConfig({
proxy: {
"/api": { target: proxyTarget, changeOrigin: true },
"/mcp": { target: proxyTarget, changeOrigin: true },
"/f": { target: proxyTarget, changeOrigin: true },
},
},
});