feat(userbot,api,frontend): file links, media downloads, story hold-pause, drop scheduled dupes
This commit is contained in:
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -12,7 +12,8 @@ export type RightPanel =
|
||||
| "stories-all"
|
||||
| "policy"
|
||||
| "watches"
|
||||
| "alerts";
|
||||
| "alerts"
|
||||
| "shares";
|
||||
|
||||
export type LeftView = "main" | "settings";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
proxy: {
|
||||
"/api": { target: proxyTarget, changeOrigin: true },
|
||||
"/mcp": { target: proxyTarget, changeOrigin: true },
|
||||
"/f": { target: proxyTarget, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user