mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
feature: add per chapter download
This commit is contained in:
1 parent
ef886644a6
commit
6f4689aa0c
6 files changed
+278
-35
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
defaultConfig,
|
||||
type AppConfig,
|
||||
type AuthPayload,
|
||||
type EpisodeRequestPayload,
|
||||
type EpisodeWatchStateRequest,
|
||||
type SeasonDeletePayload,
|
||||
type SeasonRequestPayload,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
loadJellyfinUsers,
|
||||
proxyJellyfinImage,
|
||||
removeDownload,
|
||||
requestEpisode,
|
||||
requestMovie,
|
||||
requestSeason,
|
||||
requestSeries,
|
||||
@@ -275,6 +277,14 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/requests/episode' && method === 'POST') {
|
||||
const config = await readConfig(session!.encryptionKey);
|
||||
const body = await readJson<EpisodeRequestPayload>(request);
|
||||
await requestEpisode(config, body);
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/seasons/delete' && method === 'POST') {
|
||||
const config = await readConfig(session!.encryptionKey);
|
||||
const body = await readJson<SeasonDeletePayload>(request);
|
||||
|
||||
+61
-2
@@ -3,6 +3,7 @@ import type {
|
||||
ConnectionStatus,
|
||||
DashboardResponse,
|
||||
DownloadItem,
|
||||
EpisodeRequestPayload,
|
||||
EpisodeWatchStateRequest,
|
||||
HubPageResponse,
|
||||
HubSection,
|
||||
@@ -1111,11 +1112,13 @@ async function buildSeasonEpisodes(
|
||||
const tmdbEpisode = tmdbByEpisodeNumber.get(episodeNumber);
|
||||
const jellyfinEpisode = jellyfinByEpisodeNumber.get(episodeNumber);
|
||||
const sonarrEpisode = sonarrByEpisodeNumber.get(episodeNumber);
|
||||
const queueEntry = sonarrQueue.find((entry) => entry.episode?.seasonNumber === seasonNumber && entry.episode?.episodeNumber === episodeNumber);
|
||||
const airDate = tmdbEpisode?.air_date || sonarrEpisode?.airDateUtc;
|
||||
const isReleased = !airDate || new Date(airDate).getTime() <= Date.now();
|
||||
const hasFile = Boolean(sonarrEpisode?.hasFile || sonarrEpisode?.episodeFileId);
|
||||
const isAvailableInJellyfin = Boolean(jellyfinEpisode?.id);
|
||||
const isDownloading = downloadingEpisodes.has(episodeNumber);
|
||||
const isProcessing = Boolean(sonarrEpisode?.id) && !isDownloading && Boolean(sonarrEpisode?.monitored) && availabilityWouldBeMissing(isReleased, isAvailableInJellyfin, hasFile);
|
||||
const availability: TitleDetailsEpisode['availability'] = !isReleased
|
||||
? 'unaired'
|
||||
: isAvailableInJellyfin || hasFile
|
||||
@@ -1123,9 +1126,15 @@ async function buildSeasonEpisodes(
|
||||
: isDownloading
|
||||
? 'downloading'
|
||||
: 'missing';
|
||||
const downloadStatus: TitleDetailsEpisode['downloadStatus'] = isDownloading
|
||||
? 'downloading'
|
||||
: isProcessing
|
||||
? 'processing'
|
||||
: 'idle';
|
||||
|
||||
return {
|
||||
id: jellyfinEpisode?.id || buildEpisodeId(seasonNumber, episodeNumber),
|
||||
sonarrEpisodeId: sonarrEpisode?.id,
|
||||
seasonId: season.id,
|
||||
title: jellyfinEpisode?.title || sonarrEpisode?.title || tmdbEpisode?.name || `Episode ${episodeNumber}`,
|
||||
overview: jellyfinEpisode?.overview || sonarrEpisode?.overview || tmdbEpisode?.overview || '',
|
||||
@@ -1140,6 +1149,8 @@ async function buildSeasonEpisodes(
|
||||
runtimeMinutes: jellyfinEpisode?.runtimeMinutes || tmdbEpisode?.runtime,
|
||||
availableInJellyfin: isAvailableInJellyfin,
|
||||
availability,
|
||||
downloadStatus,
|
||||
downloadProgress: isDownloading ? getEpisodeQueueProgress(queueEntry) : undefined,
|
||||
canDownload: isManagedInSonarr && availability === 'missing',
|
||||
canDelete: hasFile,
|
||||
watched: jellyfinEpisode?.watched,
|
||||
@@ -1149,6 +1160,29 @@ async function buildSeasonEpisodes(
|
||||
});
|
||||
}
|
||||
|
||||
function availabilityWouldBeMissing(
|
||||
isReleased: boolean,
|
||||
isAvailableInJellyfin: boolean,
|
||||
hasFile: boolean
|
||||
) {
|
||||
return isReleased && !isAvailableInJellyfin && !hasFile;
|
||||
}
|
||||
|
||||
function getEpisodeQueueProgress(entry: SonarrQueueRecord | undefined): number | undefined {
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const total = Number(entry.size || 0);
|
||||
const left = Number(entry.sizeleft || 0);
|
||||
if (!total || total <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const percent = Math.round(((total - left) / total) * 100);
|
||||
return Math.max(0, Math.min(100, percent));
|
||||
}
|
||||
|
||||
function buildTitleSeasons(
|
||||
seasons: SeriesSeasonDescriptor[],
|
||||
allEpisodes: TitleDetailsEpisode[],
|
||||
@@ -1177,7 +1211,9 @@ function buildTitleSeasons(
|
||||
? Math.max(season.episodeCount - availableEpisodeCount, 0)
|
||||
: undefined,
|
||||
canDownload: false,
|
||||
canDelete: availableEpisodeCount > 0,
|
||||
canDelete: typeof season.episodeCount === 'number'
|
||||
? availableEpisodeCount >= season.episodeCount
|
||||
: availableEpisodeCount > 0,
|
||||
isDownloading,
|
||||
downloadStatus: isDownloading ? 'downloading' : isProcessing ? 'processing' : 'idle',
|
||||
downloadProgress: isDownloading ? getSeasonQueueProgress(seasonQueue) : undefined,
|
||||
@@ -1202,7 +1238,7 @@ function buildTitleSeasons(
|
||||
availableEpisodeCount: seasonEpisodes.filter((episode) => episode.availability === 'available').length,
|
||||
missingEpisodeCount: seasonEpisodes.filter((episode) => episode.availability === 'missing').length,
|
||||
canDownload: seasonEpisodes.some((episode) => episode.canDownload),
|
||||
canDelete: seasonEpisodes.some((episode) => episode.canDelete),
|
||||
canDelete: seasonEpisodes.length > 0 && seasonEpisodes.every((episode) => episode.canDelete),
|
||||
isDownloading: current.isDownloading || seasonEpisodes.some((episode) => episode.availability === 'downloading')
|
||||
});
|
||||
}
|
||||
@@ -1412,6 +1448,28 @@ export async function requestSeason(config: AppConfig, payload: SeasonRequestPay
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestEpisode(config: AppConfig, payload: EpisodeRequestPayload): Promise<void> {
|
||||
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
|
||||
throw new Error('Sonarr is not configured.');
|
||||
}
|
||||
|
||||
if (!payload.episodeId) {
|
||||
throw new Error('Episode id is required.');
|
||||
}
|
||||
|
||||
await fetchJson(`${config.sonarr.baseUrl}/api/v3/command`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Api-Key': config.sonarr.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'EpisodeSearch',
|
||||
episodeIds: [payload.episodeId]
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSeasonFiles(config: AppConfig, payload: SeasonDeletePayload): Promise<void> {
|
||||
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
|
||||
throw new Error('Sonarr is not configured.');
|
||||
@@ -2392,6 +2450,7 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function jellyfinToCard(
|
||||
item: JellyfinItem,
|
||||
fallbackTitle: string,
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface MediaCard {
|
||||
|
||||
export interface TitleDetailsEpisode {
|
||||
id: string;
|
||||
sonarrEpisodeId?: number;
|
||||
seasonId?: string;
|
||||
title: string;
|
||||
overview: string;
|
||||
@@ -120,6 +121,8 @@ export interface TitleDetailsEpisode {
|
||||
runtimeMinutes?: number;
|
||||
availableInJellyfin?: boolean;
|
||||
availability?: 'available' | 'downloading' | 'missing' | 'unaired';
|
||||
downloadStatus?: 'idle' | 'processing' | 'downloading';
|
||||
downloadProgress?: number;
|
||||
canDownload?: boolean;
|
||||
canDelete?: boolean;
|
||||
watched?: boolean;
|
||||
@@ -211,6 +214,10 @@ export interface SeasonRequestPayload {
|
||||
seasonNumber: number;
|
||||
}
|
||||
|
||||
export interface EpisodeRequestPayload {
|
||||
episodeId: number;
|
||||
}
|
||||
|
||||
export interface SeasonDeletePayload {
|
||||
seriesId: number;
|
||||
seasonNumber: number;
|
||||
|
||||
+109
-18
@@ -574,7 +574,6 @@ function AuthScreen({
|
||||
<div className={isSetup ? 'auth-layout' : 'auth-layout auth-layout-login'}>
|
||||
<section className={isSetup ? 'auth-panel' : 'auth-panel auth-panel-login'}>
|
||||
<div className="auth-panel-shell">
|
||||
<p className="eyebrow">{isSetup ? 'First boot' : 'Secure access'}</p>
|
||||
<h1>{isSetup ? 'Create your master password' : 'Sign in to OmniArr'}</h1>
|
||||
<p className="auth-copy">
|
||||
{isSetup
|
||||
@@ -1048,6 +1047,7 @@ function TitleDetailsModal(props: {
|
||||
const [detailsError, setDetailsError] = useState('')
|
||||
const [activeSeasonId, setActiveSeasonId] = useState<string | undefined>(undefined)
|
||||
const [episodeWatchBusyId, setEpisodeWatchBusyId] = useState<string | null>(null)
|
||||
const [episodeDownloadBusyId, setEpisodeDownloadBusyId] = useState<string | null>(null)
|
||||
const [seasonActionBusy, setSeasonActionBusy] = useState<'download' | 'delete' | null>(null)
|
||||
const [playerTarget, setPlayerTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
const [isOverviewExpanded, setIsOverviewExpanded] = useState(false)
|
||||
@@ -1110,6 +1110,7 @@ function TitleDetailsModal(props: {
|
||||
setDetailsError('')
|
||||
setActiveSeasonId(undefined)
|
||||
setEpisodeWatchBusyId(null)
|
||||
setEpisodeDownloadBusyId(null)
|
||||
setSeasonActionBusy(null)
|
||||
setPlayerTarget(null)
|
||||
setIsOverviewExpanded(false)
|
||||
@@ -1340,6 +1341,28 @@ function TitleDetailsModal(props: {
|
||||
}
|
||||
}, [activeSeasonDownloadStatus, details, seasonValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (!details || details.mediaType !== 'series') {
|
||||
return
|
||||
}
|
||||
|
||||
const hasEpisodeTransfers = details.episodes.some((episode) =>
|
||||
episode.downloadStatus === 'processing' || episode.downloadStatus === 'downloading'
|
||||
)
|
||||
|
||||
if (!hasEpisodeTransfers) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshSeasonState(seasonValue || undefined)
|
||||
}, 2500)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [details, seasonValue])
|
||||
|
||||
function handleRequestClose() {
|
||||
if (!props.originRect || transitionState === 'closing') {
|
||||
props.onClose()
|
||||
@@ -1466,6 +1489,42 @@ function TitleDetailsModal(props: {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEpisodeDownload(
|
||||
event: MouseEvent<HTMLButtonElement>,
|
||||
episodeId: string,
|
||||
sonarrEpisodeId: number | undefined,
|
||||
availability: TitleDetailsResponse['episodes'][number]['availability']
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (!sonarrEpisodeId || availability === 'downloading' || episodeDownloadBusyId === episodeId) {
|
||||
return
|
||||
}
|
||||
|
||||
setEpisodeDownloadBusyId(episodeId)
|
||||
updateEpisodeInCaches(episodeId, (episode) => ({
|
||||
...episode,
|
||||
downloadStatus: 'processing',
|
||||
downloadProgress: undefined,
|
||||
canDownload: false,
|
||||
}))
|
||||
|
||||
void api.requestEpisode({ episodeId: sonarrEpisodeId })
|
||||
.catch(() => {
|
||||
updateEpisodeInCaches(episodeId, (episode) => ({
|
||||
...episode,
|
||||
availability,
|
||||
downloadStatus: 'idle',
|
||||
downloadProgress: undefined,
|
||||
canDownload: availability === 'missing',
|
||||
}))
|
||||
})
|
||||
.finally(() => {
|
||||
setEpisodeDownloadBusyId((current) => current === episodeId ? null : current)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={transitionState === 'closing' ? 'modal-backdrop is-closing' : 'modal-backdrop'} onClick={handleRequestClose} role="presentation">
|
||||
<div
|
||||
@@ -1606,16 +1665,17 @@ function TitleDetailsModal(props: {
|
||||
activeSeasonDownloadStatus === 'downloading' ? (
|
||||
<div
|
||||
aria-label="Season downloading"
|
||||
className="title-season-progress"
|
||||
role="img"
|
||||
title={activeSeasonMeta?.downloadLabel || 'Downloading'}
|
||||
>
|
||||
<div
|
||||
className="title-season-progress-bar"
|
||||
style={{ width: `${activeSeasonMeta?.downloadProgress || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : activeSeasonDownloadStatus === 'processing' ? (
|
||||
className="title-season-progress"
|
||||
role="img"
|
||||
title={activeSeasonMeta?.downloadLabel || 'Downloading'}
|
||||
>
|
||||
<span
|
||||
className="title-season-progress-ring"
|
||||
style={{ ['--season-download-progress' as '--season-download-progress']: `${activeSeasonMeta?.downloadProgress || 0}` }}
|
||||
/>
|
||||
<span className="title-season-progress-percent">{`${activeSeasonMeta?.downloadProgress || 0}%`}</span>
|
||||
</div>
|
||||
) : activeSeasonDownloadStatus === 'processing' ? (
|
||||
<div
|
||||
aria-label="Season processing"
|
||||
className="title-season-processing"
|
||||
@@ -1735,15 +1795,45 @@ function TitleDetailsModal(props: {
|
||||
<div className="episode-card-title-row">
|
||||
<strong>{episode.title}</strong>
|
||||
<div className="episode-card-state-icons">
|
||||
{episode.canDownload || episode.availability === 'downloading' ? (
|
||||
{episode.canDownload || episode.downloadStatus === 'processing' || episode.downloadStatus === 'downloading' ? (
|
||||
episode.downloadStatus === 'downloading' ? (
|
||||
<span
|
||||
aria-label={episode.availability === 'downloading' ? 'Episode downloading' : 'Episode available to download'}
|
||||
className={episode.availability === 'downloading' ? 'episode-card-availability is-downloading' : 'episode-card-availability'}
|
||||
title={episode.availability === 'downloading' ? 'Downloading' : 'Available to download'}
|
||||
aria-label="Episode downloading"
|
||||
className="episode-card-availability-progress"
|
||||
title={`${episode.downloadProgress || 0}% downloaded`}
|
||||
>
|
||||
<span
|
||||
className="episode-card-availability-ring"
|
||||
style={{ ['--episode-download-progress' as '--episode-download-progress']: `${episode.downloadProgress || 0}` }}
|
||||
/>
|
||||
<span className="episode-card-availability-percent">{`${episode.downloadProgress || 0}%`}</span>
|
||||
</span>
|
||||
) : episode.downloadStatus === 'processing' ? (
|
||||
<span
|
||||
aria-label="Episode processing"
|
||||
className="episode-card-availability-processing"
|
||||
title="Processing"
|
||||
>
|
||||
<span className="episode-card-processing-spinner" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
aria-label="Download episode"
|
||||
className="episode-card-availability episode-card-availability-button"
|
||||
disabled={episodeDownloadBusyId === episode.id}
|
||||
onClick={(event) => handleEpisodeDownload(
|
||||
event,
|
||||
episode.id,
|
||||
episode.sonarrEpisodeId,
|
||||
episode.availability
|
||||
)}
|
||||
title="Available to download"
|
||||
type="button"
|
||||
>
|
||||
<DownloadGlyph />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
{episode.availableInJellyfin ? (
|
||||
<button
|
||||
aria-label={episode.watched ? 'Mark episode as unwatched' : 'Mark episode as watched'}
|
||||
@@ -1971,7 +2061,7 @@ function DownloadRow(props: {
|
||||
</div>
|
||||
<button
|
||||
aria-label={`Delete ${item.title}`}
|
||||
className="download-delete-button"
|
||||
className="download-action-button download-delete-button"
|
||||
disabled={props.busy}
|
||||
onClick={confirmDelete}
|
||||
type="button"
|
||||
@@ -1987,6 +2077,7 @@ function DownloadRow(props: {
|
||||
|
||||
function humanizeDownloadState(state: string) {
|
||||
return state
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase())
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ServiceOptionsResponse,
|
||||
SessionResponse,
|
||||
EpisodeWatchStateRequest,
|
||||
EpisodeRequestPayload,
|
||||
SeasonDeletePayload,
|
||||
SeasonRequestPayload,
|
||||
TitleDetailsRequest,
|
||||
@@ -112,6 +113,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
requestEpisode: (payload: EpisodeRequestPayload) =>
|
||||
request<{ ok: boolean }>('/api/requests/episode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteSeason: (payload: SeasonDeletePayload) =>
|
||||
request<{ ok: boolean }>('/api/seasons/delete', {
|
||||
method: 'POST',
|
||||
|
||||
+85
-15
@@ -1783,25 +1783,26 @@ img {
|
||||
}
|
||||
|
||||
.title-season-progress {
|
||||
width: 2.5rem;
|
||||
gap: 0.48rem;
|
||||
min-width: 4.1rem;
|
||||
}
|
||||
|
||||
.title-season-progress::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 0.28rem;
|
||||
.title-season-progress-ring {
|
||||
--season-download-progress: 0;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
background:
|
||||
radial-gradient(circle at center, #090b10 48%, transparent 50%),
|
||||
conic-gradient(#ffffff calc(var(--season-download-progress) * 1%), rgba(255, 255, 255, 0.14) 0);
|
||||
}
|
||||
|
||||
.title-season-progress-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 0.28rem;
|
||||
border-radius: 999px;
|
||||
background: #e50914;
|
||||
.title-season-progress-percent {
|
||||
color: #eef4ff;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.episode-dock {
|
||||
@@ -2092,6 +2093,12 @@ img {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.episode-card-availability-button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.episode-card-availability .title-action-icon {
|
||||
width: 0.98rem;
|
||||
height: 0.98rem;
|
||||
@@ -2101,6 +2108,58 @@ img {
|
||||
animation: episodeCardAvailabilityPulse 900ms ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.episode-card-availability-processing {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
}
|
||||
|
||||
.episode-card-processing-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
border-top-color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 999px;
|
||||
animation: episodeDockSpinner 720ms linear infinite;
|
||||
}
|
||||
|
||||
.episode-card-availability-progress {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: rgba(244, 247, 255, 0.9);
|
||||
}
|
||||
|
||||
.episode-card-availability-ring {
|
||||
--episode-download-progress: 0;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at center, #090b10 46%, transparent 48%),
|
||||
conic-gradient(#ffffff calc(var(--episode-download-progress) * 1%), rgba(255, 255, 255, 0.14) 0);
|
||||
}
|
||||
|
||||
.episode-card-availability-percent {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(100% + 0.28rem);
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
transition: opacity 160ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.episode-card-availability-progress:hover .episode-card-availability-percent {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.episode-card-watch-toggle svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
@@ -2419,13 +2478,24 @@ img {
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.download-delete-button {
|
||||
.download-action-button {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: #dbe2ee;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.download-action-button .title-action-icon {
|
||||
width: 0.92rem;
|
||||
height: 0.92rem;
|
||||
}
|
||||
|
||||
.download-delete-button {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
text-indent: 0.06rem;
|
||||
|
||||
Reference in new issue
Block a user