Add new tool which will be for discovery map packs, currently it just lists playlists
This commit is contained in:
parent
7eb7680e16
commit
0b1146de71
@ -1,4 +1,10 @@
|
||||
import * as nodeFs from 'node:fs';
|
||||
import * as nodePath from 'node:path';
|
||||
import * as nodeOs from 'node:os';
|
||||
|
||||
const BASE_URL = 'https://api.beatsaver.com';
|
||||
// Many API calls are undocumented and only work on the web API, e.g. searchPlaylists
|
||||
const WEB_API_BASE_URL = 'https://beatsaver.com/api';
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
@ -28,6 +34,183 @@ export interface EnvironmentMapInfo extends CuratedSongInfo {
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
type DateInput = string | number | Date;
|
||||
|
||||
export interface BeatSaverUser {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar: string;
|
||||
description?: string | null;
|
||||
hash?: string | null;
|
||||
verifiedMapper?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PlaylistStats {
|
||||
totalMaps: number;
|
||||
mapperCount: number;
|
||||
totalDuration: number;
|
||||
minNps: number;
|
||||
maxNps: number;
|
||||
upVotes: number;
|
||||
downVotes: number;
|
||||
avgScore: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PlaylistBasic {
|
||||
playlistId: number;
|
||||
playlistImage: string;
|
||||
name: string;
|
||||
type: string;
|
||||
owner: number;
|
||||
config?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PlaylistFull {
|
||||
playlistId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
playlistImage: string;
|
||||
playlistImage512?: string | null;
|
||||
owner: BeatSaverUser;
|
||||
curator?: BeatSaverUser | null;
|
||||
stats?: PlaylistStats | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
songsChangedAt?: string | null;
|
||||
curatedAt?: string | null;
|
||||
deletedAt?: string | null;
|
||||
downloadURL: string;
|
||||
type: string;
|
||||
config?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MapMetadata {
|
||||
bpm: number;
|
||||
duration: number;
|
||||
songName: string;
|
||||
songSubName: string;
|
||||
songAuthorName: string;
|
||||
levelAuthorName: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MapStats {
|
||||
plays: number;
|
||||
downloads: number;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
score: number;
|
||||
reviews?: number;
|
||||
sentiment?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MapDifficulty {
|
||||
njs: number;
|
||||
offset: number;
|
||||
notes: number;
|
||||
bombs: number;
|
||||
obstacles: number;
|
||||
nps: number;
|
||||
length: number;
|
||||
characteristic: string;
|
||||
difficulty: string;
|
||||
events: number;
|
||||
chroma: boolean;
|
||||
me: boolean;
|
||||
ne: boolean;
|
||||
cinema: boolean;
|
||||
vivify?: boolean;
|
||||
seconds: number;
|
||||
stars?: number;
|
||||
maxScore: number;
|
||||
label?: string | null;
|
||||
blStars?: number | null;
|
||||
environment?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MapVersion {
|
||||
hash: string;
|
||||
key?: string | null;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
downloadURL: string;
|
||||
coverURL: string;
|
||||
previewURL: string;
|
||||
diffs?: MapDifficulty[];
|
||||
feedback?: string | null;
|
||||
testplayAt?: string | null;
|
||||
testplays?: unknown;
|
||||
scheduledAt?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MapDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
uploader: BeatSaverUser;
|
||||
metadata: MapMetadata;
|
||||
stats: MapStats;
|
||||
versions: MapVersion[];
|
||||
curator?: BeatSaverUser | null;
|
||||
curatedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
lastPublishedAt?: string | null;
|
||||
automapper?: boolean;
|
||||
ranked?: boolean;
|
||||
qualified?: boolean;
|
||||
tags?: unknown;
|
||||
bookmarked?: boolean | null;
|
||||
collaborators?: BeatSaverUser[] | null;
|
||||
declaredAi?: unknown;
|
||||
blRanked?: boolean;
|
||||
blQualified?: boolean;
|
||||
nsfw?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MapDetailWithOrder {
|
||||
map: MapDetail;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface PlaylistPage {
|
||||
playlist: PlaylistFull | null;
|
||||
maps: MapDetailWithOrder[] | null;
|
||||
}
|
||||
|
||||
export interface PlaylistSearchResponse<T = PlaylistFull> {
|
||||
docs: T[];
|
||||
info?: unknown;
|
||||
}
|
||||
|
||||
export interface PlaylistSearchParams {
|
||||
query?: string;
|
||||
page?: number;
|
||||
sortOrder?: string;
|
||||
order?: string;
|
||||
ascending?: boolean;
|
||||
includeEmpty?: boolean;
|
||||
curated?: boolean;
|
||||
verified?: boolean;
|
||||
minNps?: number;
|
||||
maxNps?: number;
|
||||
from?: DateInput;
|
||||
to?: DateInput;
|
||||
}
|
||||
|
||||
export interface PlaylistDetailOptions {
|
||||
page?: number | null;
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
interface BeatSaverApiOptions {
|
||||
cacheExpiryDays?: number;
|
||||
cacheDir?: string;
|
||||
@ -198,6 +381,90 @@ export class BeatSaverAPI {
|
||||
return processed;
|
||||
}
|
||||
|
||||
async searchPlaylists(params: PlaylistSearchParams = {}): Promise<PlaylistSearchResponse> {
|
||||
const {
|
||||
query = '',
|
||||
page = 0,
|
||||
sortOrder = 'Relevance',
|
||||
order,
|
||||
ascending,
|
||||
includeEmpty,
|
||||
curated,
|
||||
verified,
|
||||
minNps,
|
||||
maxNps,
|
||||
from,
|
||||
to
|
||||
} = params;
|
||||
|
||||
const normalizeDate = (value?: DateInput): string | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'number') return new Date(value).toISOString();
|
||||
return value;
|
||||
};
|
||||
|
||||
const url = `${WEB_API_BASE_URL}/playlists/search/${page}${buildQuery({
|
||||
q: query,
|
||||
sortOrder,
|
||||
order,
|
||||
ascending,
|
||||
includeEmpty,
|
||||
curated,
|
||||
verified,
|
||||
minNps,
|
||||
maxNps,
|
||||
from: normalizeDate(from),
|
||||
to: normalizeDate(to)
|
||||
})}`;
|
||||
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver searchPlaylists failed: ${res.status}`);
|
||||
return (await res.json()) as PlaylistSearchResponse;
|
||||
}
|
||||
|
||||
async getPlaylistDetail(
|
||||
playlistId: number | string,
|
||||
options: PlaylistDetailOptions = {}
|
||||
): Promise<PlaylistPage> {
|
||||
const { page = null, useCache = true } = options;
|
||||
const rawId = String(playlistId);
|
||||
const safeId = encodeURIComponent(rawId);
|
||||
const cacheSafeId = rawId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const pageSuffix = page === null ? 'detail' : `page_${page}`;
|
||||
const cachePath = this.pathJoin(this.cacheDir, `playlist_${cacheSafeId}_${pageSuffix}.json`);
|
||||
|
||||
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||
const cached = await this.readCache<PlaylistPage>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const endpoint =
|
||||
page === null
|
||||
? `${WEB_API_BASE_URL}/playlists/id/${safeId}`
|
||||
: `${WEB_API_BASE_URL}/playlists/id/${safeId}/${page}`;
|
||||
|
||||
const res = await this.request(endpoint);
|
||||
if (!res.ok) throw new Error(`BeatSaver getPlaylistDetail failed: ${res.status}`);
|
||||
const payload = (await res.json()) as PlaylistPage;
|
||||
|
||||
if (useCache) {
|
||||
await this.writeCache(cachePath, payload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async getPlaylistMaps(
|
||||
playlistId: number | string,
|
||||
page = 0,
|
||||
options: { useCache?: boolean } = {}
|
||||
): Promise<MapDetailWithOrder[]> {
|
||||
const { useCache = true } = options;
|
||||
const detail = await this.getPlaylistDetail(playlistId, { page, useCache });
|
||||
return detail.maps ?? [];
|
||||
}
|
||||
|
||||
async getMapByHash(mapHash: string): Promise<unknown> {
|
||||
const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`;
|
||||
const res = await this.request(url);
|
||||
@ -304,21 +571,16 @@ export class BeatSaverAPI {
|
||||
|
||||
// Lazy require Node builtins to keep SSR-friendly import graph
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as typeof import('fs').promises;
|
||||
return nodeFs.promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('fs') as typeof import('fs');
|
||||
return nodeFs;
|
||||
}
|
||||
private pathModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('path') as typeof import('path');
|
||||
return nodePath;
|
||||
}
|
||||
private osModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('os') as typeof import('os');
|
||||
return nodeOs;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -105,7 +105,8 @@ const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
|
||||
export const TOOL_REQUIREMENTS = {
|
||||
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
||||
'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'map-pack-discovery': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
||||
} as const satisfies Record<string, ToolRequirement>;
|
||||
|
||||
export type ToolKey = keyof typeof TOOL_REQUIREMENTS;
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
{#each [
|
||||
{ name: 'Compare Play Histories', href: '/tools/compare-histories', desc: 'Find songs A played that B has not' },
|
||||
{ name: 'Player Playlist Gaps', href: '/tools/player-playlist-gaps', desc: 'Upload a playlist and find songs a player has not played' },
|
||||
{ name: 'Player Head-to-Head', href: '/tools/player-headtohead', desc: 'Compare two players on the same map/difficulty' }
|
||||
{ name: 'Player Head-to-Head', href: '/tools/player-headtohead', desc: 'Compare two players on the same map/difficulty' },
|
||||
{ name: 'Map Pack Discovery', href: '/tools/map-pack-discovery', desc: 'Browse curated BeatSaver playlists with mapper context' }
|
||||
] as tool}
|
||||
<a href={tool.href} class="card-surface p-5 block">
|
||||
<div class="font-semibold">{tool.name}</div>
|
||||
|
||||
66
src/routes/tools/map-pack-discovery/+page.server.ts
Normal file
66
src/routes/tools/map-pack-discovery/+page.server.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { createBeatSaverAPI, type PlaylistFull, type PlaylistSearchResponse } from '$lib/server/beatsaver';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
function parsePositiveInt(value: string | null, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
type SearchInfoShape = {
|
||||
page?: number;
|
||||
pages?: number;
|
||||
total?: number;
|
||||
size?: number;
|
||||
itemsPerPage?: number;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch, parent }) => {
|
||||
const parentData = await parent();
|
||||
const requestedPage = parsePositiveInt(url.searchParams.get('page'), 1);
|
||||
const pageIndex = requestedPage - 1;
|
||||
|
||||
const api = createBeatSaverAPI(fetch);
|
||||
|
||||
try {
|
||||
const response = (await api.searchPlaylists({ page: pageIndex })) as PlaylistSearchResponse<PlaylistFull>;
|
||||
const docs = Array.isArray(response?.docs) ? response.docs : [];
|
||||
const info = (response?.info && typeof response.info === 'object' ? (response.info as SearchInfoShape) : null) ?? null;
|
||||
|
||||
const infoPage = typeof info?.page === 'number' ? info.page : pageIndex;
|
||||
const currentPage = infoPage + 1;
|
||||
const totalPages = typeof info?.pages === 'number' ? info.pages : null;
|
||||
const total = typeof info?.total === 'number' ? info.total : null;
|
||||
const pageSize = typeof info?.size === 'number'
|
||||
? info.size
|
||||
: typeof info?.itemsPerPage === 'number'
|
||||
? info.itemsPerPage
|
||||
: docs.length;
|
||||
|
||||
return {
|
||||
...parentData,
|
||||
playlists: docs,
|
||||
info,
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
total,
|
||||
pageSize,
|
||||
error: null
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load BeatSaver playlists', err);
|
||||
return {
|
||||
...parentData,
|
||||
playlists: [] as PlaylistFull[],
|
||||
info: null,
|
||||
page: requestedPage,
|
||||
totalPages: null,
|
||||
total: null,
|
||||
pageSize: 0,
|
||||
error: err instanceof Error ? err.message : 'Failed to load playlists'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
204
src/routes/tools/map-pack-discovery/+page.svelte
Normal file
204
src/routes/tools/map-pack-discovery/+page.svelte
Normal file
@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import HasToolAccess from '$lib/components/HasToolAccess.svelte';
|
||||
import type { PlaylistFull } from '$lib/server/beatsaver';
|
||||
import type { BeatLeaderPlayerProfile } from '$lib/utils/plebsaber-utils';
|
||||
import { TOOL_REQUIREMENTS } from '$lib/utils/plebsaber-utils';
|
||||
|
||||
type PlaylistWithStats = PlaylistFull & { stats?: PlaylistFull['stats'] };
|
||||
|
||||
export let data: {
|
||||
playlists: PlaylistWithStats[];
|
||||
page: number;
|
||||
totalPages: number | null;
|
||||
total: number | null;
|
||||
pageSize: number;
|
||||
error: string | null;
|
||||
player: BeatLeaderPlayerProfile | null;
|
||||
adminRank: number | null;
|
||||
adminPlayer: BeatLeaderPlayerProfile | null;
|
||||
};
|
||||
|
||||
const requirement = TOOL_REQUIREMENTS['map-pack-discovery'];
|
||||
|
||||
const playlistBaseUrl = 'https://beatsaver.com/playlists/';
|
||||
const profileBaseUrl = 'https://beatsaver.com/profile/';
|
||||
|
||||
$: playlists = data?.playlists ?? [];
|
||||
$: page = data?.page ?? 1;
|
||||
$: totalPages = data?.totalPages ?? null;
|
||||
$: total = data?.total ?? null;
|
||||
$: pageSize = data?.pageSize ?? 0;
|
||||
|
||||
$: hasPrev = page > 1;
|
||||
$: hasNext = totalPages != null ? page < totalPages : playlists.length > 0 && playlists.length === pageSize;
|
||||
$: prevHref = hasPrev ? `?page=${page - 1}` : null;
|
||||
$: nextHref = hasNext ? `?page=${page + 1}` : null;
|
||||
|
||||
function formatScore(avgScore: number | undefined | null): string | null {
|
||||
if (typeof avgScore !== 'number' || !Number.isFinite(avgScore)) return null;
|
||||
return `${(avgScore * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function playlistLink(id: number | string | undefined): string {
|
||||
if (id === undefined || id === null) return playlistBaseUrl;
|
||||
return `${playlistBaseUrl}${id}`;
|
||||
}
|
||||
|
||||
function profileLink(id: number | string | undefined): string {
|
||||
if (id === undefined || id === null) return profileBaseUrl;
|
||||
return `${profileBaseUrl}${id}#playlists`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="py-8">
|
||||
<h1 class="font-display text-3xl sm:text-4xl">Map Pack Discovery</h1>
|
||||
<p class="mt-2 text-muted max-w-2xl">
|
||||
Browse curated BeatSaver playlists with quick access to pack covers, curators, and their overall rating.
|
||||
</p>
|
||||
|
||||
<HasToolAccess player={data?.player ?? null} requirement={requirement} adminRank={data?.adminRank ?? null} adminPlayer={data?.adminPlayer ?? null}>
|
||||
{#if data?.error}
|
||||
<div class="mt-6 rounded-md border border-danger/40 bg-danger/10 px-4 py-3 text-danger text-sm">
|
||||
{data.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !data?.error && playlists.length === 0}
|
||||
<div class="mt-6 text-muted">No playlists found for this page.</div>
|
||||
{/if}
|
||||
|
||||
{#if playlists.length > 0}
|
||||
<div class="mt-6 flex flex-wrap items-center gap-3 text-sm text-muted">
|
||||
<span>Page {page}{#if totalPages} of {totalPages}{/if}</span>
|
||||
{#if total != null}
|
||||
<span>• {total.toLocaleString()} total playlists</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{#each playlists as playlist (playlist.playlistId)}
|
||||
<article class="card-surface overflow-hidden">
|
||||
<a class="block" href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer">
|
||||
<figure class="playlist-cover">
|
||||
<img
|
||||
src={playlist.playlistImage}
|
||||
alt={`Playlist cover for ${playlist.name}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</figure>
|
||||
</a>
|
||||
<div class="p-4 space-y-3">
|
||||
<div>
|
||||
<a class="playlist-title" href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer">{playlist.name}</a>
|
||||
{#if playlist.description}
|
||||
<p class="mt-1 line-clamp-3 text-sm text-muted">{playlist.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-muted">
|
||||
<span>
|
||||
{#if playlist.owner?.name}
|
||||
<a class="hover:text-white transition-colors" href={profileLink(playlist.owner.id)} target="_blank" rel="noopener noreferrer">{playlist.owner.name}</a>
|
||||
{:else}
|
||||
Unknown curator
|
||||
{/if}
|
||||
</span>
|
||||
{#if formatScore(playlist.stats?.avgScore)}
|
||||
<span class="font-medium text-white">{formatScore(playlist.stats?.avgScore)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center justify-center gap-3">
|
||||
<a class="pager-btn" href={prevHref} aria-disabled={!hasPrev} data-disabled={!hasPrev}>
|
||||
Prev
|
||||
</a>
|
||||
<span class="text-sm text-muted">Page {page}{#if totalPages} / {totalPages}{/if}</span>
|
||||
<a class="pager-btn" href={nextHref} aria-disabled={!hasNext} data-disabled={!hasNext}>
|
||||
Next
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</HasToolAccess>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.playlist-cover {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.8), rgba(30, 58, 138, 0.4));
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playlist-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.playlist-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pager-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem 1.1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pager-btn:hover {
|
||||
border-color: rgba(94, 234, 212, 0.5);
|
||||
box-shadow: 0 0 12px rgba(94, 234, 212, 0.2);
|
||||
}
|
||||
|
||||
.pager-btn[data-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-surface {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.bg-danger\/10 {
|
||||
background-color: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
.border-danger\/40 {
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user