forked from Keelrleek/SoulStream
playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
+591
-62
@@ -1,132 +1,661 @@
|
||||
<script>
|
||||
// The sidebar's Playlists row needs a destination. Playlists used to be
|
||||
// listed in the sidebar itself; with the sidebar showing listening history
|
||||
// instead, this is where they live.
|
||||
import { playlistStore, refreshPlaylists, goToView } from "../lib/state.svelte.js";
|
||||
// The playlist shelf: every playlist as a cover card — custom art or the
|
||||
// composed four-cover mosaic — with pinning, duplication, export, and the
|
||||
// two import doors (a playlist file, a pasted tracklist).
|
||||
import {
|
||||
playlistStore,
|
||||
refreshPlaylists,
|
||||
playlistCoverFor,
|
||||
goToView,
|
||||
playAlbum,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./../components/Icon.svelte";
|
||||
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
|
||||
|
||||
let importOpen = $state(false);
|
||||
let importMode = $state("file"); // "file" | "paste"
|
||||
let importDraft = $state(null);
|
||||
let menuFor = $state(null); // playlist id whose ⋯ menu is open
|
||||
let confirmDelete = $state(null); // playlist id armed for deletion
|
||||
let creating = $state(false);
|
||||
let newMenuOpen = $state(false);
|
||||
let probing = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!playlistStore.loaded) refreshPlaylists();
|
||||
});
|
||||
|
||||
// Covers resolve lazily through the store cache; re-render never refetches.
|
||||
$effect(() => {
|
||||
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
|
||||
});
|
||||
|
||||
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
|
||||
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
|
||||
|
||||
async function newPlaylist() {
|
||||
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
||||
const id = await api.createPlaylist(name);
|
||||
await refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: name });
|
||||
if (creating) return;
|
||||
creating = true;
|
||||
try {
|
||||
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
|
||||
const id = await api.createPlaylist(name);
|
||||
await refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: name });
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importFromFile() {
|
||||
newMenuOpen = false;
|
||||
if (probing) return;
|
||||
probing = true;
|
||||
try {
|
||||
const draft = await api.playlistImportProbe();
|
||||
if (draft) {
|
||||
importDraft = draft;
|
||||
importMode = "file";
|
||||
importOpen = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("playlist import probe failed", e);
|
||||
} finally {
|
||||
probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importFromFolder() {
|
||||
newMenuOpen = false;
|
||||
if (probing) return;
|
||||
probing = true;
|
||||
try {
|
||||
const draft = await api.playlistImportFolder();
|
||||
if (draft) {
|
||||
importDraft = draft;
|
||||
importMode = "file";
|
||||
importOpen = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("folder import failed", e);
|
||||
} finally {
|
||||
probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function importFromText() {
|
||||
newMenuOpen = false;
|
||||
importDraft = null;
|
||||
importMode = "paste";
|
||||
importOpen = true;
|
||||
}
|
||||
|
||||
function open(pl) {
|
||||
goToView("playlist", { playlistId: pl.id, label: pl.name });
|
||||
}
|
||||
|
||||
// Hover-play, straight from the card — the Library's own affordance.
|
||||
// Generation-guarded: a slower fetch from an EARLIER click must not
|
||||
// override the playlist clicked after it.
|
||||
let playCardGen = 0;
|
||||
async function playCard(pl) {
|
||||
const gen = ++playCardGen;
|
||||
const rows = await api.playlistItems(pl.id);
|
||||
if (gen !== playCardGen) return;
|
||||
const playable = rows.filter((r) => r.path && !r.missing);
|
||||
if (!playable.length) {
|
||||
open(pl); // nothing playable: the page explains why
|
||||
return;
|
||||
}
|
||||
playAlbum(
|
||||
playable.map((r) => ({
|
||||
path: r.path,
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
album: r.album,
|
||||
duration_secs: r.duration_secs,
|
||||
art_path: r.art_path,
|
||||
plItemId: r.id,
|
||||
})),
|
||||
pl.name,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function toggleMenu(e, pl) {
|
||||
e.stopPropagation();
|
||||
confirmDelete = null;
|
||||
menuFor = menuFor === pl.id ? null : pl.id;
|
||||
}
|
||||
|
||||
async function togglePin(pl) {
|
||||
menuFor = null;
|
||||
await api.setPlaylistPinned(pl.id, !pl.pinned);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
|
||||
async function duplicate(pl) {
|
||||
menuFor = null;
|
||||
const id = await api.duplicatePlaylist(pl.id, `${pl.name} copy`);
|
||||
await refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: `${pl.name} copy` });
|
||||
}
|
||||
|
||||
async function exportAs(pl, format) {
|
||||
menuFor = null;
|
||||
try {
|
||||
await api.playlistExport(pl.id, format);
|
||||
} catch (e) {
|
||||
console.error("playlist export failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(pl) {
|
||||
if (confirmDelete !== pl.id) {
|
||||
confirmDelete = pl.id;
|
||||
return;
|
||||
}
|
||||
menuFor = null;
|
||||
confirmDelete = null;
|
||||
await api.deletePlaylist(pl.id);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
|
||||
function onImported(id, name) {
|
||||
importOpen = false;
|
||||
importDraft = null;
|
||||
refreshPlaylists();
|
||||
goToView("playlist", { playlistId: id, label: name });
|
||||
}
|
||||
|
||||
function fmtDuration(totalSecs) {
|
||||
if (!totalSecs) return "";
|
||||
const h = Math.floor(totalSecs / 3600);
|
||||
const m = Math.round((totalSecs % 3600) / 60);
|
||||
return h > 0 ? `${h} hr ${m} min` : `${m} min`;
|
||||
}
|
||||
|
||||
// Close any open menu on a page click.
|
||||
function pageClick() {
|
||||
menuFor = null;
|
||||
newMenuOpen = false;
|
||||
confirmDelete = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={pageClick} />
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<h1>Playlists</h1>
|
||||
<button class="new" onclick={newPlaylist}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</button>
|
||||
<div class="head-text">
|
||||
<h1>Playlists</h1>
|
||||
{#if playlistStore.playlists.length}
|
||||
<p class="sub">
|
||||
{playlistStore.playlists.length}
|
||||
{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
class="btn ghost"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
menuFor = null;
|
||||
newMenuOpen = !newMenuOpen;
|
||||
}}
|
||||
>
|
||||
<Icon name="download" size={15} /> Import
|
||||
<Icon name="chevron-down" size={13} />
|
||||
</button>
|
||||
{#if newMenuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button class="mi" onclick={importFromFile} disabled={probing}>
|
||||
<Icon name="file-text" size={15} />
|
||||
Playlist file…
|
||||
<span class="hint">M3U · XSPF · PLS · CSV · JSON</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromFolder} disabled={probing}>
|
||||
<Icon name="folder-open" size={15} />
|
||||
Folder of audio files…
|
||||
<span class="hint">tag or natural order</span>
|
||||
</button>
|
||||
<button class="mi" onclick={importFromText}>
|
||||
<Icon name="pencil" size={15} />
|
||||
Paste a tracklist…
|
||||
<span class="hint">plain lines or timestamped setlist</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn primary" onclick={newPlaylist} disabled={creating}>
|
||||
<Icon name="plus" size={15} /> New playlist
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if playlistStore.playlists.length === 0}
|
||||
<p class="empty">No playlists yet.</p>
|
||||
<div class="empty">
|
||||
<div class="empty-icon"><Icon name="playlist" size={44} /></div>
|
||||
<p class="empty-title">No playlists yet</p>
|
||||
<p class="empty-sub">
|
||||
Create one, or import an M3U/XSPF file or a pasted tracklist.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each playlistStore.playlists as pl (pl.id)}
|
||||
<li>
|
||||
<button class="row" onclick={() => open(pl)}>
|
||||
<span class="ic"><Icon name="playlist" size={18} /></span>
|
||||
<span class="name">{pl.name}</span>
|
||||
<span class="count">
|
||||
{pl.track_count}
|
||||
{pl.track_count === 1 ? "track" : "tracks"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{#if pinned.length}
|
||||
<div class="lab"><Icon name="pin" size={12} /> Pinned</div>
|
||||
<div class="grid">
|
||||
{#each pinned as pl (pl.id)}
|
||||
{@render card(pl)}
|
||||
{/each}
|
||||
</div>
|
||||
{#if rest.length}
|
||||
<div class="lab all">All playlists</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="grid">
|
||||
{#each rest as pl (pl.id)}
|
||||
{@render card(pl)}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet card(pl)}
|
||||
<div class="card-wrap">
|
||||
<button class="card" type="button" onclick={() => open(pl)}>
|
||||
<span class="artwrap">
|
||||
{#if playlistStore.covers[pl.id]}
|
||||
<img class="art" src={playlistStore.covers[pl.id]} alt="" draggable="false" />
|
||||
{:else}
|
||||
<div class="art fallback">
|
||||
<Icon name="playlist" size={38} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if pl.track_count > pl.unresolved_count}
|
||||
<span
|
||||
class="hoverplay"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="Play"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
playCard(pl);
|
||||
}}
|
||||
onkeydown={(e) => e.key === "Enter" && (e.stopPropagation(), playCard(pl))}
|
||||
><Icon name="play" size={13} /></span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="card-title" title={pl.name}>
|
||||
{pl.name}
|
||||
</div>
|
||||
<div class="card-sub">
|
||||
{pl.track_count} {pl.track_count === 1 ? "track" : "tracks"}
|
||||
{#if pl.duration_secs} · {fmtDuration(pl.duration_secs)}{/if}
|
||||
</div>
|
||||
{#if pl.kind === "setlist" || pl.unresolved_count > 0}
|
||||
<div class="badges">
|
||||
{#if pl.kind === "setlist"}<span class="badge set">Setlist</span>{/if}
|
||||
{#if pl.unresolved_count > 0}
|
||||
<span class="badge un">{pl.unresolved_count} unresolved</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if pl.pinned}
|
||||
<span class="pinned-mark" title="Pinned"><Icon name="pin" size={12} /></span>
|
||||
{/if}
|
||||
<button class="more" title="More" onclick={(e) => toggleMenu(e, pl)}>
|
||||
<Icon name="more-horizontal" size={17} />
|
||||
</button>
|
||||
{#if menuFor === pl.id}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<div class="menu card-menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="mi" onclick={() => togglePin(pl)}>
|
||||
<Icon name="pin" size={15} /> {pl.pinned ? "Unpin" : "Pin"}
|
||||
</button>
|
||||
<button class="mi" onclick={() => duplicate(pl)}>
|
||||
<Icon name="layers" size={15} /> Duplicate
|
||||
</button>
|
||||
<div class="rule"></div>
|
||||
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>
|
||||
<Icon name="export" size={15} /> Export M3U8
|
||||
</button>
|
||||
<button class="mi" onclick={() => exportAs(pl, "xspf")}>
|
||||
<Icon name="export" size={15} /> Export XSPF
|
||||
</button>
|
||||
<button class="mi" onclick={() => exportAs(pl, "pls")}>
|
||||
<Icon name="export" size={15} /> Export PLS
|
||||
</button>
|
||||
<button class="mi" onclick={() => exportAs(pl, "csv")}>
|
||||
<Icon name="export" size={15} /> Export CSV
|
||||
</button>
|
||||
<button class="mi" onclick={() => exportAs(pl, "json")}>
|
||||
<Icon name="export" size={15} /> Export backup (JSON)
|
||||
</button>
|
||||
<div class="rule"></div>
|
||||
<button class="mi danger" onclick={() => remove(pl)}>
|
||||
<Icon name="trash" size={15} />
|
||||
{confirmDelete === pl.id ? "Really delete?" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<PlaylistImportModal
|
||||
bind:open={importOpen}
|
||||
mode={importMode}
|
||||
draft={importDraft}
|
||||
onimported={onImported}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 8px 4px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.new {
|
||||
.sub {
|
||||
margin-top: 3px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-highlight);
|
||||
font-weight: 700;
|
||||
transition:
|
||||
background 0.14s,
|
||||
color 0.14s;
|
||||
color 0.14s,
|
||||
transform 0.14s;
|
||||
}
|
||||
.new:hover {
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: var(--bg-press);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.row {
|
||||
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 230px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
z-index: 40;
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.14s;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row:hover {
|
||||
.mi:hover:not(:disabled) {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ic {
|
||||
.mi:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.mi.danger:hover {
|
||||
color: #f15e6c;
|
||||
}
|
||||
.mi .hint {
|
||||
margin-left: auto;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
padding-left: 14px;
|
||||
}
|
||||
.menu .rule {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 5px 8px;
|
||||
}
|
||||
|
||||
.lab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin: 4px 2px 12px;
|
||||
}
|
||||
.lab.all {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius);
|
||||
padding: 13px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.card:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.artwrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
.art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
/* The Library card's own hover-play, verbatim in spirit: accent circle
|
||||
riding the art's corner, revealed by the card hover. */
|
||||
.hoverplay {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 5px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
background 0.18s ease;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
.hoverplay:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.card-wrap:hover .hoverplay,
|
||||
.hoverplay:focus-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.art.fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, var(--accent-soft), var(--bg-press) 72%);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2.5px 7px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.badge.set {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.badge.un {
|
||||
background: rgba(232, 163, 61, 0.15);
|
||||
color: #e8a33d;
|
||||
}
|
||||
|
||||
.pinned-mark {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(6px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.more {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.card-wrap:hover .more,
|
||||
.more:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.more:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.card-menu {
|
||||
top: 48px;
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 14vh;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 24px;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.empty-sub {
|
||||
margin-top: 5px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
max-width: 380px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user