feat(userbot,api,frontend): file links, media downloads, story hold-pause, drop scheduled dupes
This commit is contained in:
1 parent
683b9a31a3
commit
3e08698b62
38 files changed
+2889
-134
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user