feat: full discography, explicit badges, smart selection, and sorting

Pagination: raise discography limit from 50 to 500 across all three API
call sites (artist-detail page, discography download modal, source-detail
view). Deezer/iTunes/Spotify already paginated internally; Discogs updated
to paginate all pages instead of a single capped request.

Explicit flag: add explicit field to the Album dataclass in every metadata
source (Deezer, Spotify, iTunes, Discogs, MusicBrainz, Hydrabase). Deezer
extracts explicit_lyrics, iTunes extracts collectionExplicitness, Hydrabase
forwards the field from the server response; Discogs/MusicBrainz/Spotify
stay None. The field threads through _build_discography_release_dict and
_build_artist_detail_release_card so it reaches the frontend on every
release object. A small "E" badge renders next to explicit album titles in
both the discography download modal and artist-detail page cards.

Smart selection: "Select Best" button in the discography download modal
groups visible cards by normalised title (edition/variant suffixes stripped)
and within each group checks only the highest-scoring version. Scoring
mirrors the existing Python _variant_score logic: explicit > non-clean >
no variant suffix > more tracks > newer release date.

Sort controls: Date desc/asc and Name A-Z/Z-A dropdown added to the
discography modal filter bar. Sorting reorders DOM nodes in place so
checked state is preserved across sort changes.

UI: discography card titles now wrap to full text instead of truncating
with ellipsis; card artwork and checkbox pin to the top when titles wrap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dlynas 2026-04-30 00:02:49 -04:00
parent 6db2cbe2a7
commit 1afc79a64b
11 changed files with 194 additions and 18 deletions

View file

@ -126,7 +126,7 @@ def build_source_only_artist_detail(
allow_fallback=True, allow_fallback=True,
skip_cache=False, skip_cache=False,
max_pages=0, max_pages=0,
limit=50, limit=500,
artist_source_ids={source: artist_id}, artist_source_ids={source: artist_id},
dedup_variants=False, dedup_variants=False,
), ),

View file

@ -168,6 +168,7 @@ class Album:
album_type: str album_type: str
image_url: Optional[str] = None image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None external_urls: Optional[Dict[str, str]] = None
explicit: Optional[bool] = None
@classmethod @classmethod
def from_deezer_album(cls, album_data: Dict[str, Any]) -> 'Album': def from_deezer_album(cls, album_data: Dict[str, Any]) -> 'Album':
@ -199,7 +200,8 @@ class Album:
total_tracks=album_data.get('nb_tracks', 0), total_tracks=album_data.get('nb_tracks', 0),
album_type=album_type, album_type=album_type,
image_url=image_url, image_url=image_url,
external_urls=external_urls if external_urls else None external_urls=external_urls if external_urls else None,
explicit=bool(album_data.get('explicit_lyrics', False)),
) )

View file

@ -209,6 +209,7 @@ class Album:
album_type: str album_type: str
image_url: Optional[str] = None image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None external_urls: Optional[Dict[str, str]] = None
explicit: Optional[bool] = None
@classmethod @classmethod
def from_discogs_release(cls, release_data: Dict[str, Any]) -> 'Album': def from_discogs_release(cls, release_data: Dict[str, Any]) -> 'Album':
@ -543,10 +544,26 @@ class DiscogsClient:
artist_data = self._api_get(f'/artists/{artist_id}') artist_data = self._api_get(f'/artists/{artist_id}')
artist_name = artist_data.get('name', '').lower() if artist_data else '' artist_name = artist_data.get('name', '').lower() if artist_data else ''
data = self._api_get(f'/artists/{artist_id}/releases', { # Paginate through all releases (Discogs max per_page=100).
'sort': 'year', 'sort_order': 'desc', 'per_page': min(limit * 3, 200), # We collect raw items across pages before filtering so the limit cap
}) # applies to qualified albums, not raw API rows.
if not data or not data.get('releases'): PAGE_SIZE = 100
all_items = []
page = 1
while True:
data = self._api_get(f'/artists/{artist_id}/releases', {
'sort': 'year', 'sort_order': 'desc',
'per_page': PAGE_SIZE, 'page': page,
})
if not data or not data.get('releases'):
break
all_items.extend(data['releases'])
pagination = data.get('pagination', {})
if page >= pagination.get('pages', 1):
break
page += 1
if not all_items:
return [] return []
# Separate masters from individual releases — prefer masters (canonical versions) # Separate masters from individual releases — prefer masters (canonical versions)
@ -554,7 +571,7 @@ class DiscogsClient:
releases_no_master = [] releases_no_master = []
master_titles = set() master_titles = set()
for item in data['releases']: for item in all_items:
# Skip non-main roles # Skip non-main roles
role = item.get('role', 'Main').lower() role = item.get('role', 'Main').lower()
if role not in ('main', ''): if role not in ('main', ''):
@ -595,7 +612,8 @@ class DiscogsClient:
album = Album(id=album.id, name=album.name, artists=album.artists, album = Album(id=album.id, name=album.name, artists=album.artists,
release_date=album.release_date, total_tracks=album.total_tracks, release_date=album.release_date, total_tracks=album.total_tracks,
album_type=album.album_type, image_url=thumb, album_type=album.album_type, image_url=thumb,
external_urls=album.external_urls) external_urls=album.external_urls,
explicit=album.explicit)
# Deduplicate by normalized title (but keep deluxe/special editions as separate) # Deduplicate by normalized title (but keep deluxe/special editions as separate)
dedup_key = album.name.lower().strip() dedup_key = album.name.lower().strip()
@ -607,7 +625,7 @@ class DiscogsClient:
if album.album_type in allowed_types: if album.album_type in allowed_types:
albums.append(album) albums.append(album)
if len(albums) >= limit: if limit and len(albums) >= limit:
break break
except Exception as e: except Exception as e:
logger.debug(f"Error parsing Discogs artist release: {e}") logger.debug(f"Error parsing Discogs artist release: {e}")

View file

@ -565,6 +565,7 @@ class HydrabaseClient:
album_type=item_type, album_type=item_type,
image_url=item.get('image_url'), image_url=item.get('image_url'),
external_urls=ext_urls, external_urls=ext_urls,
explicit=item.get('explicit'),
)) ))
except Exception as e: except Exception as e:
logger.debug(f"Skipping malformed Hydrabase artist album: {e}") logger.debug(f"Skipping malformed Hydrabase artist album: {e}")

View file

@ -167,6 +167,7 @@ class Album:
album_type: str album_type: str
image_url: Optional[str] = None image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None external_urls: Optional[Dict[str, str]] = None
explicit: Optional[bool] = None
@classmethod @classmethod
def from_itunes_album(cls, album_data: Dict[str, Any]) -> 'Album': def from_itunes_album(cls, album_data: Dict[str, Any]) -> 'Album':
@ -209,7 +210,8 @@ class Album:
total_tracks=track_count, total_tracks=track_count,
album_type=album_type, album_type=album_type,
image_url=image_url, image_url=image_url,
external_urls=external_urls if external_urls else None external_urls=external_urls if external_urls else None,
explicit=album_data.get('collectionExplicitness') == 'explicit',
) )
@dataclass @dataclass

View file

@ -97,6 +97,9 @@ def _build_discography_release_dict(release: Any, artist_id: str) -> Optional[Di
album_type = _extract_lookup_value(release, 'album_type', default='album') or 'album' album_type = _extract_lookup_value(release, 'album_type', default='album') or 'album'
release_date = _extract_lookup_value(release, 'release_date') release_date = _extract_lookup_value(release, 'release_date')
raw_explicit = _extract_lookup_value(release, 'explicit')
explicit: Optional[bool] = bool(raw_explicit) if raw_explicit is not None else None
return { return {
'id': release_id, 'id': release_id,
'name': _extract_lookup_value(release, 'name', 'title', default=release_id), 'name': _extract_lookup_value(release, 'name', 'title', default=release_id),
@ -106,6 +109,7 @@ def _build_discography_release_dict(release: Any, artist_id: str) -> Optional[Di
'image_url': _extract_lookup_value(release, 'image_url', 'thumb_url', 'cover_image'), 'image_url': _extract_lookup_value(release, 'image_url', 'thumb_url', 'cover_image'),
'total_tracks': _extract_lookup_value(release, 'total_tracks', default=0) or 0, 'total_tracks': _extract_lookup_value(release, 'total_tracks', default=0) or 0,
'external_urls': _extract_lookup_value(release, 'external_urls', default={}) or {}, 'external_urls': _extract_lookup_value(release, 'external_urls', default={}) or {},
'explicit': explicit,
} }
@ -359,6 +363,9 @@ def _build_artist_detail_release_card(release: Dict[str, Any]) -> Optional[Dict[
if release_year is not None: if release_year is not None:
release_year = str(release_year) release_year = str(release_year)
raw_explicit = _extract_lookup_value(release, 'explicit')
card_explicit: Optional[bool] = bool(raw_explicit) if raw_explicit is not None else None
card = { card = {
'id': release_id, 'id': release_id,
'name': _extract_lookup_value(release, 'name', 'title', default=release_id), 'name': _extract_lookup_value(release, 'name', 'title', default=release_id),
@ -369,6 +376,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any]) -> Optional[Dict[
'track_count': _extract_lookup_value(release, 'track_count', 'total_tracks', default=0) or 0, 'track_count': _extract_lookup_value(release, 'track_count', 'total_tracks', default=0) or 0,
'owned': None, 'owned': None,
'track_completion': 'checking', 'track_completion': 'checking',
'explicit': card_explicit,
} }
if release_date: if release_date:

View file

@ -57,6 +57,7 @@ class Album:
album_type: str album_type: str
image_url: Optional[str] = None image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None external_urls: Optional[Dict[str, str]] = None
explicit: Optional[bool] = None
def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]: def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]:

View file

@ -429,6 +429,7 @@ class Album:
image_url: Optional[str] = None image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None external_urls: Optional[Dict[str, str]] = None
artist_ids: Optional[List[str]] = None artist_ids: Optional[List[str]] = None
explicit: Optional[bool] = None
@classmethod @classmethod
def from_spotify_album(cls, album_data: Dict[str, Any]) -> 'Album': def from_spotify_album(cls, album_data: Dict[str, Any]) -> 'Album':

View file

@ -8553,7 +8553,7 @@ def get_artist_detail(artist_id):
allow_fallback=True, allow_fallback=True,
skip_cache=False, skip_cache=False,
max_pages=0, max_pages=0,
limit=50, limit=500,
artist_source_ids=artist_source_ids, artist_source_ids=artist_source_ids,
), ),
) )
@ -8783,7 +8783,7 @@ def get_artist_discography(artist_id):
allow_fallback=True, allow_fallback=True,
skip_cache=False, skip_cache=False,
max_pages=0, max_pages=0,
limit=50, limit=500,
), ),
) )

View file

@ -1611,8 +1611,9 @@ function createReleaseCard(release) {
const content = document.createElement("div"); const content = document.createElement("div");
content.className = "album-card-content"; content.className = "album-card-content";
const _esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const _esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const explicitTag = release.explicit === true ? '<span class="album-card-explicit">E</span>' : '';
content.innerHTML = ` content.innerHTML = `
<div class="album-card-name" title="${_esc(release.title)}">${_esc(release.title)}</div> <div class="album-card-name" title="${_esc(release.title)}">${_esc(release.title)}${explicitTag}</div>
${yearText ? `<div class="album-card-year">${_esc(yearText)}</div>` : ''} ${yearText ? `<div class="album-card-year">${_esc(yearText)}</div>` : ''}
`; `;
card.appendChild(content); card.appendChild(content);
@ -2204,9 +2205,16 @@ async function openDiscographyModal() {
<button class="discog-filter active" data-type="ep" onclick="toggleDiscogFilter(this)">EPs</button> <button class="discog-filter active" data-type="ep" onclick="toggleDiscogFilter(this)">EPs</button>
<button class="discog-filter active" data-type="single" onclick="toggleDiscogFilter(this)">Singles</button> <button class="discog-filter active" data-type="single" onclick="toggleDiscogFilter(this)">Singles</button>
</div> </div>
<select class="discog-sort-select" id="discog-sort-select" onchange="discogApplySort(this.value)" title="Sort releases">
<option value="date-desc">Date </option>
<option value="date-asc">Date </option>
<option value="name-asc">Name AZ</option>
<option value="name-desc">Name ZA</option>
</select>
<div class="discog-select-actions"> <div class="discog-select-actions">
<button class="discog-select-btn" onclick="discogSelectAll(true)">Select All</button> <button class="discog-select-btn" onclick="discogSelectAll(true)">Select All</button>
<button class="discog-select-btn" onclick="discogSelectAll(false)">Deselect All</button> <button class="discog-select-btn" onclick="discogSelectAll(false)">Deselect All</button>
<button class="discog-select-btn discog-select-best-btn" onclick="discogSelectBest()" title="Select one best version of each album (prefers explicit, expanded, more tracks)">Select Best</button>
</div> </div>
</div> </div>
<div class="discog-grid" id="discog-grid"> <div class="discog-grid" id="discog-grid">
@ -2250,17 +2258,18 @@ function _renderDiscogCard(release, index, completionData) {
const checked = !isOwned; const checked = !isOwned;
const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : ''; const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : '';
const statusIcon = isOwned ? '✓' : isPartial ? '◐' : ''; const statusIcon = isOwned ? '✓' : isPartial ? '◐' : '';
const explicitBadge = release.explicit === true ? '<span class="explicit-badge">E</span>' : '';
const albumName = release.name || release.title || ''; const albumName = release.name || release.title || '';
return ` return `
<label class="discog-card ${statusClass}" data-type="${release._type}" style="animation-delay:${index * 0.03}s"> <label class="discog-card ${statusClass}" data-type="${release._type}" data-explicit="${release.explicit === true ? '1' : '0'}" data-tracks-count="${tracks}" data-release-date="${release.release_date || ''}" data-title="${_esc(albumName)}" style="animation-delay:${index * 0.03}s">
<input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-album-name="${_esc(albumName)}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()"> <input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-album-name="${_esc(albumName)}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()">
<div class="discog-card-art"> <div class="discog-card-art">
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'} ${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
${statusIcon ? `<span class="discog-card-status">${statusIcon}</span>` : ''} ${statusIcon ? `<span class="discog-card-status">${statusIcon}</span>` : ''}
</div> </div>
<div class="discog-card-info"> <div class="discog-card-info">
<div class="discog-card-title">${_esc(albumName)}</div> <div class="discog-card-title">${_esc(albumName)}${explicitBadge}</div>
<div class="discog-card-meta">${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}</div> <div class="discog-card-meta">${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}</div>
</div> </div>
<div class="discog-card-check"></div> <div class="discog-card-check"></div>
@ -2286,6 +2295,87 @@ function discogSelectAll(select) {
_updateDiscogFooterCount(); _updateDiscogFooterCount();
} }
// Strip edition/variant suffixes to get a normalized base title for grouping.
// Mirrors the Python _clean_title logic in metadata_service.py.
function _discogNormalizeTitle(raw) {
let t = (raw || '').trim().toLowerCase();
const variantSuffix = /\s*[\(\[][^\(\)\[\]]*\b(?:edition|editions|deluxe|remaster|remastered|explicit|clean|version|anniversary|collector|expanded|redux)\b[^\(\)\[\]]*[\)\]]\s*$/i;
const legacySuffix = /\s*-\s*(explicit|clean|deluxe edition|single)\s*$/i;
let prev;
do {
prev = t;
t = t.replace(variantSuffix, '').replace(legacySuffix, '').trim();
} while (t !== prev);
return t.replace(/\s+/g, ' ').trim();
}
// Score a discog-card element — higher is better.
// Returns a comparable array: [notCompilation, noVariantSuffix, explicitScore, trackCount, releaseDate]
function _discogCardScore(card) {
const title = (card.dataset.title || '').toLowerCase();
const isExplicit = card.dataset.explicit === '1';
const isClean = !isExplicit && /\bclean\b/.test(title);
const trackCount = parseInt(card.dataset.tracksCount) || 0;
const releaseDate = card.dataset.releaseDate || '';
const variantKeyword = /\b(?:edition|editions|deluxe|remaster|remastered|explicit|clean|version|anniversary|collector|expanded|redux)\b/i;
const hasVariantSuffix = /[\(\[][^\)\]]*/.test(title) && variantKeyword.test(title);
const isCompilation = /\b(greatest hits|best of|collection|anthology|essential)\b/i.test(title);
return [
isCompilation ? 0 : 1,
hasVariantSuffix ? 0 : 1,
isExplicit ? 2 : (isClean ? 0 : 1),
trackCount,
releaseDate,
];
}
function _scoreGt(a, b) {
for (let i = 0; i < a.length; i++) {
if (a[i] > b[i]) return true;
if (a[i] < b[i]) return false;
}
return false;
}
function discogSelectBest() {
const cards = Array.from(document.querySelectorAll('.discog-card')).filter(
c => c.style.display !== 'none'
);
// Group cards by (type, normalizedTitle)
const groups = new Map();
const groupOrder = [];
cards.forEach(card => {
const type = card.dataset.type || 'album';
const rawTitle = card.dataset.title || '';
const key = type + '\x00' + _discogNormalizeTitle(rawTitle);
if (!groups.has(key)) {
groups.set(key, []);
groupOrder.push(key);
}
groups.get(key).push(card);
});
// Within each group, find the best card; uncheck all others
const bestCards = new Set();
groupOrder.forEach(key => {
const group = groups.get(key);
let best = group[0];
let bestScore = _discogCardScore(best);
for (let i = 1; i < group.length; i++) {
const s = _discogCardScore(group[i]);
if (_scoreGt(s, bestScore)) { best = group[i]; bestScore = s; }
}
bestCards.add(best);
});
cards.forEach(card => {
const cb = card.querySelector('.discog-card-cb');
if (cb) cb.checked = bestCards.has(card);
});
_updateDiscogFooterCount();
}
function _updateDiscogFooterCount() { function _updateDiscogFooterCount() {
const checked = document.querySelectorAll('.discog-card-cb:checked'); const checked = document.querySelectorAll('.discog-card-cb:checked');
let releases = 0, tracks = 0; let releases = 0, tracks = 0;
@ -2303,6 +2393,23 @@ function _updateDiscogFooterCount() {
if (submitBtn) submitBtn.disabled = releases === 0; if (submitBtn) submitBtn.disabled = releases === 0;
} }
function discogApplySort(order) {
const grid = document.getElementById('discog-grid');
if (!grid) return;
const cards = Array.from(grid.querySelectorAll('.discog-card'));
cards.sort((a, b) => {
if (order === 'date-desc' || order === 'date-asc') {
const da = a.dataset.releaseDate || '';
const db = b.dataset.releaseDate || '';
return order === 'date-desc' ? db.localeCompare(da) : da.localeCompare(db);
}
const ta = (a.dataset.title || '').toLowerCase();
const tb = (b.dataset.title || '').toLowerCase();
return order === 'name-asc' ? ta.localeCompare(tb) : tb.localeCompare(ta);
});
cards.forEach(c => grid.appendChild(c));
}
async function startDiscographyDownload() { async function startDiscographyDownload() {
let artist = artistsPageState.selectedArtist; let artist = artistsPageState.selectedArtist;
// Fallback to library page state // Fallback to library page state

View file

@ -45082,6 +45082,16 @@ textarea.enhanced-meta-field-input {
color: rgb(var(--accent-light-rgb)); color: rgb(var(--accent-light-rgb));
} }
.discog-sort-select {
padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 600;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
color: rgba(255,255,255,0.4); cursor: pointer; transition: all 0.15s ease;
font-family: inherit; appearance: none; -webkit-appearance: none;
outline: none;
}
.discog-sort-select:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.15); }
.discog-sort-select option { background: #1a1a2e; color: #fff; }
.discog-select-actions { display: flex; gap: 6px; } .discog-select-actions { display: flex; gap: 6px; }
.discog-select-btn { .discog-select-btn {
@ -45093,6 +45103,16 @@ textarea.enhanced-meta-field-input {
.discog-select-btn:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.15); } .discog-select-btn:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.15); }
.discog-select-best-btn {
border-color: rgba(var(--accent-rgb), 0.25);
color: rgba(var(--accent-light-rgb), 0.7);
}
.discog-select-best-btn:hover {
background: rgba(var(--accent-rgb), 0.08);
border-color: rgba(var(--accent-rgb), 0.4);
color: rgb(var(--accent-light-rgb));
}
/* Album grid */ /* Album grid */
.discog-section-header { .discog-section-header {
grid-column: 1 / -1; grid-column: 1 / -1;
@ -45115,7 +45135,7 @@ textarea.enhanced-meta-field-input {
} }
.discog-card { .discog-card {
display: flex; align-items: center; gap: 10px; display: flex; align-items: flex-start; gap: 10px;
padding: 8px 10px; border-radius: 10px; padding: 8px 10px; border-radius: 10px;
background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04);
cursor: pointer; transition: all 0.15s ease; cursor: pointer; transition: all 0.15s ease;
@ -45152,11 +45172,27 @@ textarea.enhanced-meta-field-input {
.discog-card-title { .discog-card-title {
font-size: 13px; font-weight: 600; color: #fff; font-size: 13px; font-weight: 600; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: normal; word-break: break-word;
} }
.discog-card-meta { font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 2px; } .discog-card-meta { font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 2px; }
.explicit-badge {
display: inline-block; font-size: 9px; font-weight: 800; letter-spacing: 0.04em;
padding: 1px 4px; border-radius: 3px; vertical-align: middle; margin-left: 5px;
background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.55);
border: 1px solid rgba(255,255,255,0.15); line-height: 1.4; flex-shrink: 0;
font-family: inherit;
}
.album-card-explicit {
display: inline-block; font-size: 9px; font-weight: 800; letter-spacing: 0.04em;
padding: 1px 4px; border-radius: 3px; vertical-align: middle; margin-left: 4px;
background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.55);
border: 1px solid rgba(255,255,255,0.2); line-height: 1.4;
font-family: inherit; text-shadow: none;
}
.discog-card-check { .discog-card-check {
width: 20px; height: 20px; border-radius: 6px; flex-shrink: 0; width: 20px; height: 20px; border-radius: 6px; flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.15); transition: all 0.15s ease; border: 2px solid rgba(255,255,255,0.15); transition: all 0.15s ease;