feat(frontend): watches, webhooks and alerts panels read humanly, with chat picker, url filter, test send and delivery log
This commit is contained in:
1 parent
6a4fd6ad47
commit
ea3a7c0a61
19 files changed
+3699
-458
No files matched your search
@@ -1,82 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Alert } from "#lib/api/types.js";
|
||||
import Button from "../ui/Button.svelte";
|
||||
import Icon from "../ui/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
alerts: Alert[];
|
||||
onseen: (alert: Alert) => void;
|
||||
}
|
||||
|
||||
const { alerts, onseen }: Props = $props();
|
||||
|
||||
function text(alert: Alert): string {
|
||||
const p = alert.payload;
|
||||
const value = p.text ?? p.message ?? p.keyword;
|
||||
return typeof value === "string" ? value : JSON.stringify(p);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul class="alert-list">
|
||||
{#each alerts as alert (alert.id)}
|
||||
<li class="alert-item" class:unseen={!alert.seen}>
|
||||
<div class="alert-main">
|
||||
<p class="alert-text">{text(alert)}</p>
|
||||
<time class="alert-date">{new Date(alert.ts).toLocaleString()}</time>
|
||||
</div>
|
||||
{#if !alert.seen}
|
||||
<Button
|
||||
aria-label="Отметить прочитанным"
|
||||
onclick={() => onseen(alert)}
|
||||
round
|
||||
smaller
|
||||
variant="translucent"
|
||||
>
|
||||
<Icon name="check" />
|
||||
</Button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
.alert-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-borders);
|
||||
border-radius: 0.75rem;
|
||||
|
||||
&.unseen {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.alert-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +1,640 @@
|
||||
<script lang="ts">
|
||||
import { listAlerts, markAlertSeen } from "#lib/api/endpoints.js";
|
||||
import type { Alert } from "#lib/api/types.js";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import {
|
||||
type AlertItem,
|
||||
listAlerts,
|
||||
markAlertSeen,
|
||||
markAllAlertsSeen,
|
||||
} from "#lib/api/watches.js";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
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 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, formatListDate } from "#lib/format/datetime.js";
|
||||
import {
|
||||
alertAvatar,
|
||||
alertLabel,
|
||||
alertPreview,
|
||||
alertTitle,
|
||||
EVENT_ICONS,
|
||||
} from "#lib/format/watches.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import Spinner from "../ui/Spinner.svelte";
|
||||
import AlertList from "./AlertList.svelte";
|
||||
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
import { isMobile } from "#lib/viewport.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
let alerts = $state<Alert[]>([]);
|
||||
type Filter = "all" | "unseen";
|
||||
|
||||
const PAGE = 50;
|
||||
|
||||
let items = $state<AlertItem[]>([]);
|
||||
let nextBefore = $state<number | null>(null);
|
||||
let loading = $state(true);
|
||||
let unseenOnly = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let failed = $state(false);
|
||||
let notifyProblem = $state(false);
|
||||
let token = 0;
|
||||
|
||||
async function load(_account: number | null, unseen: boolean) {
|
||||
loading = true;
|
||||
let filter = $state<Filter>(
|
||||
untrack(() => page.url.searchParams.get(ALERTS_PARAM)) === "unseen"
|
||||
? "unseen"
|
||||
: "all"
|
||||
);
|
||||
|
||||
function writeParam(value: Filter | null) {
|
||||
const url = new URL(page.url.href);
|
||||
if (value === null) {
|
||||
url.searchParams.delete(ALERTS_PARAM);
|
||||
} else {
|
||||
url.searchParams.set(ALERTS_PARAM, value);
|
||||
}
|
||||
if (url.href !== page.url.href) {
|
||||
goto(url, { replace: true, reset: false });
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(next: Filter) {
|
||||
filter = next;
|
||||
writeParam(next);
|
||||
}
|
||||
|
||||
async function load(current: Filter, before: number | null = null) {
|
||||
token += 1;
|
||||
const mine = token;
|
||||
if (before === null) {
|
||||
loading = true;
|
||||
} else {
|
||||
loadingMore = true;
|
||||
}
|
||||
try {
|
||||
alerts = await listAlerts(unseen ? { seen: false } : {});
|
||||
const result = await listAlerts({
|
||||
before: before ?? undefined,
|
||||
limit: PAGE,
|
||||
seen: current === "unseen" ? false : undefined,
|
||||
});
|
||||
if (mine !== token) {
|
||||
return;
|
||||
}
|
||||
items = before === null ? result.items : [...items, ...result.items];
|
||||
nextBefore = result.next_before;
|
||||
failed = false;
|
||||
} catch {
|
||||
if (mine === token) {
|
||||
failed = true;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
if (mine === token) {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load(accounts.selectedId, unseenOnly);
|
||||
if (accounts.selectedId !== null) {
|
||||
load(filter);
|
||||
}
|
||||
});
|
||||
|
||||
async function seen(alert: Alert) {
|
||||
await markAlertSeen(alert.id);
|
||||
if (unseenOnly) {
|
||||
alerts = alerts.filter((a) => a.id !== alert.id);
|
||||
} else {
|
||||
alerts = alerts.map((a) =>
|
||||
a.id === alert.id ? { ...a, seen: true } : a
|
||||
);
|
||||
onMount(() => {
|
||||
writeParam(filter);
|
||||
const stop = alerts.onAlert((alert) => {
|
||||
if (!items.some((item) => item.id === alert.id)) {
|
||||
items = [alert, ...items];
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
stop();
|
||||
writeParam(null);
|
||||
};
|
||||
});
|
||||
|
||||
function markLocal(alert: AlertItem) {
|
||||
if (alert.seen) {
|
||||
return;
|
||||
}
|
||||
items = items.map((item) =>
|
||||
item.id === alert.id ? { ...item, seen: true } : item
|
||||
);
|
||||
alerts.read();
|
||||
}
|
||||
|
||||
async function seen(alert: AlertItem) {
|
||||
if (alert.seen) {
|
||||
return;
|
||||
}
|
||||
markLocal(alert);
|
||||
await markAlertSeen(alert.id).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function openAlert(alert: AlertItem) {
|
||||
seen(alert);
|
||||
await goto(alert.path);
|
||||
if (isMobile()) {
|
||||
ui.closePanel();
|
||||
}
|
||||
}
|
||||
|
||||
async function seeAll() {
|
||||
items = items.map((item) => ({ ...item, seen: true }));
|
||||
alerts.readAll();
|
||||
await markAllAlertsSeen().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function toggleNotify() {
|
||||
notifyProblem = false;
|
||||
const wanted = !alerts.notify;
|
||||
const done = await alerts.setNotify(wanted);
|
||||
notifyProblem = wanted && !done;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel-content">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={unseenOnly}>
|
||||
<span>Только непрочитанные</span>
|
||||
</label>
|
||||
|
||||
{#if loading}
|
||||
<Spinner />
|
||||
{:else if alerts.length === 0}
|
||||
<p class="empty">Алертов нет.</p>
|
||||
{:else}
|
||||
<AlertList {alerts} onseen={seen} />
|
||||
{/if}
|
||||
<div class="tabs">
|
||||
<button
|
||||
aria-pressed={filter === "all"}
|
||||
class="tab"
|
||||
onclick={() => setFilter("all")}
|
||||
type="button"
|
||||
class:on={filter === "all"}
|
||||
>
|
||||
{t("alerts.all")}
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={filter === "unseen"}
|
||||
class="tab"
|
||||
onclick={() => setFilter("unseen")}
|
||||
type="button"
|
||||
class:on={filter === "unseen"}
|
||||
>
|
||||
{t("alerts.unseen")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if alerts.unseen > 0}
|
||||
<button class="act" onclick={seeAll} type="button">
|
||||
<Icon name="readchats" size="1.375rem" />
|
||||
<span class="label">{t("alerts.markAll")}</span>
|
||||
<span class="value">{alerts.unseen}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if alerts.notifySupported}
|
||||
<button
|
||||
aria-checked={alerts.notify}
|
||||
class="toggle"
|
||||
onclick={toggleNotify}
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<Icon name="unmute" size="1.375rem" />
|
||||
<span class="label">{t("alerts.notify")}</span>
|
||||
<span class="switch" class:on={alerts.notify}
|
||||
><span class="knob"></span></span
|
||||
>
|
||||
</button>
|
||||
{#if notifyProblem}
|
||||
<p class="problem">{t("alerts.notifyDenied")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if loading && items.length === 0}
|
||||
<div class="state"><Spinner /></div>
|
||||
{:else if failed && items.length === 0}
|
||||
<div class="state">
|
||||
<p>{t("alerts.loadFailed")}</p>
|
||||
<Button onclick={() => load(filter)} smaller variant="text">
|
||||
{t("alerts.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="empty">
|
||||
<EmptyState
|
||||
description={filter === "unseen"
|
||||
? undefined
|
||||
: t("alerts.empty.description")}
|
||||
title={filter === "unseen"
|
||||
? t("alerts.emptyUnseen.title")
|
||||
: t("alerts.empty.title")}
|
||||
/>
|
||||
{#if filter === "all"}
|
||||
<Button onclick={() => ui.openPanel("watches")} smaller variant="text">
|
||||
{t("alerts.setup")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each items as alert (alert.id)}
|
||||
{@const avatar = alertAvatar(alert)}
|
||||
{@const preview = alertPreview(alert)}
|
||||
<li>
|
||||
<ContextMenu>
|
||||
{#snippet children({
|
||||
props,
|
||||
})}
|
||||
<button
|
||||
{...props}
|
||||
class="alert"
|
||||
onclick={() => openAlert(alert)}
|
||||
type="button"
|
||||
class:unseen={!alert.seen}
|
||||
>
|
||||
<span class="lead">
|
||||
<Avatar
|
||||
avatar={{ id: avatar.id, kind: avatar.kind }}
|
||||
colorKey={avatar.id}
|
||||
hasAvatar={avatar.hasAvatar}
|
||||
name={avatar.name}
|
||||
size={3}
|
||||
/>
|
||||
<span class="kind {alert.event}">
|
||||
<Icon name={EVENT_ICONS[alert.event]} size="0.75rem" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="text">
|
||||
<span class="top">
|
||||
<span class="title">{alertTitle(alert)}</span>
|
||||
<time datetime={alert.ts} title={formatFull(alert.ts)}>
|
||||
{formatListDate(alert.ts)}
|
||||
</time>
|
||||
</span>
|
||||
<span class="bottom">
|
||||
<span class="line">
|
||||
<span class="what" class:alone={!preview}>
|
||||
{alertLabel(alert)}
|
||||
</span>
|
||||
{preview}
|
||||
</span>
|
||||
{#if !alert.seen}
|
||||
<span class="dot"></span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<ContextMenuItem
|
||||
icon={alert.message ? "reply" : "message"}
|
||||
onselect={() => openAlert(alert)}
|
||||
>
|
||||
{alert.message ? t("alerts.open") : t("alerts.openChat")}
|
||||
</ContextMenuItem>
|
||||
{#if !alert.seen}
|
||||
<ContextMenuItem icon="check" onselect={() => seen(alert)}>
|
||||
{t("alerts.markSeen")}
|
||||
</ContextMenuItem>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ContextMenu>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if nextBefore !== null}
|
||||
<button
|
||||
class="more"
|
||||
disabled={loadingMore}
|
||||
onclick={() => load(filter, nextBefore)}
|
||||
type="button"
|
||||
>
|
||||
{t("alerts.more")}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.panel-content {
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.tab {
|
||||
cursor: pointer;
|
||||
|
||||
flex: 1;
|
||||
|
||||
height: 2.75rem;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
background: none;
|
||||
|
||||
transition: color 0.15s ease-out;
|
||||
|
||||
&.on {
|
||||
border-bottom-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:not(.on):hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.act,
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
padding: 0 1rem 0 1.125rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-primary);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid currentcolor;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
color: var(--color-text);
|
||||
|
||||
:global(.icon) {
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
width: 2.25rem;
|
||||
height: 1.375rem;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.6875rem;
|
||||
|
||||
background-color: var(--color-borders-input);
|
||||
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&.on {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-white);
|
||||
|
||||
transition: transform 0.15s ease-out;
|
||||
|
||||
.on & {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
}
|
||||
|
||||
.problem {
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
|
||||
padding: 2rem 1rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
padding: 2.5rem 1rem 2rem;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.alert {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
min-height: 4.5rem;
|
||||
padding: 0.5625rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
font: inherit;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lead {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kind {
|
||||
position: absolute;
|
||||
right: -0.125rem;
|
||||
bottom: -0.125rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--color-white);
|
||||
|
||||
background-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-background);
|
||||
|
||||
&.delete {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
&.online {
|
||||
background-color: var(--color-green);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top,
|
||||
.bottom {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
font-size: 1rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.unseen & {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.line {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.what {
|
||||
color: var(--color-text);
|
||||
|
||||
&:not(.alone)::after {
|
||||
content: ":";
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.more {
|
||||
cursor: pointer;
|
||||
|
||||
width: calc(100% - 1rem);
|
||||
margin: 0 0.5rem 1rem;
|
||||
padding: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary);
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user