feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,633 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary-60 backdrop-blur-sm"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<Transition name="scale">
|
||||
<div
|
||||
v-if="show"
|
||||
class="bg-bg-secondary border border-border-secondary rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-border-secondary shrink-0">
|
||||
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">
|
||||
{{ locale.title }}
|
||||
</h3>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
|
||||
<!-- 登录状态检查 -->
|
||||
<section v-if="!isLoggedIn" class="space-y-3">
|
||||
<div class="bg-warning-10 border border-warning-20 rounded-2xl p-4">
|
||||
<p class="text-xs text-warning mb-3">{{ locale.loginRequired }}</p>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-primary-hover hover:bg-primary-hover text-text-primary text-xs font-bold rounded-xl transition-colors"
|
||||
@click="showLoginModal"
|
||||
>
|
||||
{{ locale.loginNow }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<!-- 音质选择 -->
|
||||
<section class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase text-text-disabled tracking-[0.2em] px-1"
|
||||
>{{ locale.quality }}</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="option in qualityOptions"
|
||||
:key="option.value"
|
||||
class="flex flex-col p-4 rounded-2xl border text-left transition-all relative overflow-hidden group"
|
||||
:class="[
|
||||
selectedQuality === option.value
|
||||
? 'bg-primary-hover-10 border-primary shadow-sm'
|
||||
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
|
||||
]"
|
||||
@click="selectedQuality = option.value"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1 relative z-10">
|
||||
<span
|
||||
class="text-xs font-bold transition-colors"
|
||||
:class="
|
||||
selectedQuality === option.value ? 'text-primary' : 'text-text-primary'
|
||||
"
|
||||
>{{ option.label }}</span
|
||||
>
|
||||
<div
|
||||
v-if="selectedQuality === option.value"
|
||||
class="w-1.5 h-1.5 bg-primary rounded-full shadow-[0_0_8px_var(--primary-50)]"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] text-text-tertiary relative z-10">{{
|
||||
option.description
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<section class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase text-text-disabled tracking-[0.2em] px-1"
|
||||
>{{ locale.songInfo }}</label
|
||||
>
|
||||
<div class="bg-bg-primary border border-border-secondary rounded-2xl p-4 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
v-if="song?.img || song?.cover"
|
||||
:src="song.img || song.cover"
|
||||
:alt="locale.coverAlt"
|
||||
class="w-12 h-12 rounded-lg object-cover"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-bold text-text-primary truncate">{{ songName }}</p>
|
||||
<p class="text-xs text-text-tertiary truncate">{{ artistName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<section
|
||||
v-if="uploading || uploadProgress > 0"
|
||||
class="space-y-3 pt-4 border-t border-border-secondary-50"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-text-tertiary">{{ uploadStatus }}</span>
|
||||
<span class="text-primary">{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-2 bg-bg-primary rounded-full overflow-hidden border border-border-secondary-50 relative"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-primary to-primary-hover transition-all duration-300 ease-out relative overflow-hidden"
|
||||
:style="{ width: `${uploadProgress}%` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--shimmer-overlay)] to-transparent animate-shimmer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="uploadMessage" class="text-[10px] text-text-tertiary text-center">
|
||||
{{ uploadMessage }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="isLoggedIn" class="p-4 border-t border-border-secondary shrink-0 flex gap-3">
|
||||
<button
|
||||
class="flex-1 py-3 px-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||||
:disabled="uploading"
|
||||
@click="closeDialog"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 px-4 bg-primary-hover hover:bg-primary-hover text-text-primary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="uploading"
|
||||
@click="startUpload"
|
||||
>
|
||||
{{ uploading ? locale.uploading : locale.startUpload }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useAudioQuality, QUALITY_OPTIONS } from '~/composables/useAudioQuality'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
song: any // 来源歌曲对象
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const requestLocale = computed(() => songsLocale.value?.requestForm || {})
|
||||
const locale = computed(() => useSafeLocale(requestLocale.value?.neteaseUpload || {}))
|
||||
|
||||
const songName = computed(
|
||||
() => props.song?.name || props.song?.song || props.song?.title || locale.value.unknownSong
|
||||
)
|
||||
const artistName = computed(() => props.song?.singer || props.song?.artist || locale.value.unknownArtist)
|
||||
const albumName = computed(() => props.song?.album || locale.value.unknownAlbum)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close' | 'upload-success' | 'show-login'): void
|
||||
}>()
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
|
||||
// 音质选项
|
||||
const qualityOptions = QUALITY_OPTIONS.tencent
|
||||
|
||||
const selectedQuality = ref(8) // 默认高音质
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const uploadStatus = ref('')
|
||||
const uploadMessage = ref('')
|
||||
const isLoggedIn = ref(false)
|
||||
const getLocaleText = (key, ...args) => formatLocaleValue(locale.value?.[key], ...args)
|
||||
|
||||
// 检查登录状态
|
||||
const checkLoginStatus = () => {
|
||||
if (import.meta.client) {
|
||||
const cookie = localStorage.getItem('netease_cookie')
|
||||
isLoggedIn.value = !!cookie
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkLoginStatus()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
checkLoginStatus()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const closeDialog = () => {
|
||||
if (!uploading.value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const showLoginModal = () => {
|
||||
emit('show-login')
|
||||
}
|
||||
|
||||
// 获取网易云音乐Cookie
|
||||
const getNeteaseCookie = () => {
|
||||
if (import.meta.client) {
|
||||
return localStorage.getItem('netease_cookie') || ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 获取QQ音乐下载链接
|
||||
const getQQMusicUrl = async (strMediaMid: string, quality: number): Promise<string> => {
|
||||
uploadStatus.value = locale.value.statusGetDownloadUrl
|
||||
|
||||
if (!strMediaMid) {
|
||||
throw new Error(locale.value.missingSongId)
|
||||
}
|
||||
|
||||
// 使用vkeys API获取QQ音乐链接
|
||||
const apiUrl = `https://api.vkeys.cn/v2/music/tencent?id=${strMediaMid}&quality=${quality}`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getLocaleText('requestFailed', response.status))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200 && data.data && data.data.url) {
|
||||
let url = data.data.url
|
||||
// 统一改为安全协议链接
|
||||
if (url.startsWith('http://')) {
|
||||
url = url.replace('http://', 'https://')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
throw new Error(locale.value.invalidPlayUrl)
|
||||
}
|
||||
|
||||
// 通过文件头识别音频格式
|
||||
const detectAudioType = async (blob: Blob): Promise<string | null> => {
|
||||
const arr = new Uint8Array(await blob.slice(0, 12).arrayBuffer())
|
||||
|
||||
if (arr[0] === 0x66 && arr[1] === 0x4c && arr[2] === 0x61 && arr[3] === 0x43) {
|
||||
return 'flac'
|
||||
}
|
||||
|
||||
if (arr[0] === 0x49 && arr[1] === 0x44 && arr[2] === 0x33) {
|
||||
return 'mp3'
|
||||
}
|
||||
|
||||
if (arr[0] === 0xff && (arr[1] & 0xe0) === 0xe0) {
|
||||
return 'mp3'
|
||||
}
|
||||
|
||||
if (arr[0] === 0x4f && arr[1] === 0x67 && arr[2] === 0x67 && arr[3] === 0x53) {
|
||||
return 'ogg'
|
||||
}
|
||||
|
||||
if (
|
||||
arr[0] === 0x52 &&
|
||||
arr[1] === 0x49 &&
|
||||
arr[2] === 0x46 &&
|
||||
arr[3] === 0x46 &&
|
||||
arr[8] === 0x57 &&
|
||||
arr[9] === 0x41 &&
|
||||
arr[10] === 0x56 &&
|
||||
arr[11] === 0x45
|
||||
) {
|
||||
return 'wav'
|
||||
}
|
||||
|
||||
if (
|
||||
arr[4] === 0x66 &&
|
||||
arr[5] === 0x74 &&
|
||||
arr[6] === 0x79 &&
|
||||
arr[7] === 0x70 &&
|
||||
arr[8] === 0x4d &&
|
||||
arr[9] === 0x34 &&
|
||||
arr[10] === 0x41 &&
|
||||
arr[11] === 0x20
|
||||
) {
|
||||
return 'm4a'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 下载音频文件
|
||||
const downloadAudio = async (url: string): Promise<{ blob: Blob; ext: string }> => {
|
||||
uploadStatus.value = locale.value.statusDownloading
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(getLocaleText('downloadFailed', response.status))
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type')
|
||||
const contentLength = response.headers.get('content-length')
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(locale.value.emptyResponse)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let receivedLength = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
chunks.push(value)
|
||||
receivedLength += value.length
|
||||
|
||||
if (total > 0) {
|
||||
// 下载进度占总进度的50%
|
||||
uploadProgress.value = Math.floor((receivedLength / total) * 50)
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks as BlobPart[], { type: contentType || 'audio/mpeg' })
|
||||
|
||||
// 尝试通过文件头检测真实格式
|
||||
let ext = await detectAudioType(blob)
|
||||
|
||||
if (!ext) {
|
||||
// 回退:按响应类型判断
|
||||
if (contentType) {
|
||||
switch (true) {
|
||||
case contentType.includes('audio/flac') || contentType.includes('application/x-flac'):
|
||||
ext = 'flac'
|
||||
break
|
||||
case contentType.includes('audio/wav') || contentType.includes('audio/x-wav'):
|
||||
ext = 'wav'
|
||||
break
|
||||
case contentType.includes('audio/ogg'):
|
||||
ext = 'ogg'
|
||||
break
|
||||
case contentType.includes('audio/aac') || contentType.includes('audio/mp4'):
|
||||
ext = 'm4a'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:按链接后缀判断
|
||||
if (!ext) {
|
||||
if (url.includes('.flac')) {
|
||||
ext = 'flac'
|
||||
} else {
|
||||
ext = 'mp3' // 默认使用常规格式
|
||||
}
|
||||
}
|
||||
|
||||
uploadProgress.value = 50
|
||||
return { blob, ext }
|
||||
}
|
||||
|
||||
// 上传到网易云音乐
|
||||
const uploadToNetease = async (audioBlob: Blob, filename: string) => {
|
||||
uploadStatus.value = locale.value.statusHashing
|
||||
const arrayBuffer = await audioBlob.arrayBuffer()
|
||||
const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer)
|
||||
const md5 = CryptoJS.MD5(wordArray).toString()
|
||||
const fileSize = audioBlob.size
|
||||
const cookie = getNeteaseCookie()
|
||||
|
||||
const baseApiUrl = '/api/api-enhanced/netease'
|
||||
|
||||
// 获取文件扩展名
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || 'mp3'
|
||||
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
flac: 'audio/flac',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
m4a: 'audio/mp4',
|
||||
mp3: 'audio/mpeg'
|
||||
}
|
||||
|
||||
const contentType = contentTypeMap[ext] || 'audio/mpeg'
|
||||
|
||||
// 获取上传凭证
|
||||
uploadStatus.value = locale.value.statusToken
|
||||
const tokenUrl = `${baseApiUrl}/cloud/upload/token?time=${Date.now()}`
|
||||
|
||||
const tokenRes = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cookie,
|
||||
md5,
|
||||
fileSize,
|
||||
filename
|
||||
})
|
||||
})
|
||||
|
||||
const tokenData = await tokenRes.json()
|
||||
|
||||
if (tokenData.code !== 200) {
|
||||
throw new Error(getLocaleText('tokenFailed', tokenData.msg || tokenData.code))
|
||||
}
|
||||
|
||||
const { needUpload, uploadUrl, uploadToken, objectKey, resourceId, songId } = tokenData.data
|
||||
|
||||
if (needUpload) {
|
||||
// 上传到对象存储
|
||||
uploadStatus.value = locale.value.statusUploading
|
||||
|
||||
// 使用上传事件获取进度
|
||||
await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.floor((e.loaded / e.total) * 100)
|
||||
// 上传阶段进度映射到 50%~95%
|
||||
uploadProgress.value = 50 + Math.floor(percent * 0.45)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.responseText)
|
||||
} else {
|
||||
reject(new Error(getLocaleText('nosFailed', xhr.status)))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error(locale.value.nosNetworkError))
|
||||
})
|
||||
|
||||
xhr.open('POST', uploadUrl)
|
||||
xhr.setRequestHeader('x-nos-token', uploadToken)
|
||||
xhr.setRequestHeader('Content-MD5', md5)
|
||||
xhr.setRequestHeader('Content-Type', contentType)
|
||||
xhr.send(audioBlob)
|
||||
})
|
||||
} else {
|
||||
uploadProgress.value = 95
|
||||
uploadStatus.value = locale.value.statusExists
|
||||
}
|
||||
|
||||
// 完成上传并写入云盘信息
|
||||
uploadStatus.value = locale.value.statusSaving
|
||||
|
||||
const completeUrl = `${baseApiUrl}/cloud/upload/complete?time=${Date.now()}`
|
||||
const completeRes = await fetch(completeUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cookie,
|
||||
md5,
|
||||
songId,
|
||||
resourceId,
|
||||
filename,
|
||||
song: songName.value,
|
||||
artist: artistName.value,
|
||||
album: albumName.value,
|
||||
bitrate: 999000
|
||||
})
|
||||
})
|
||||
|
||||
const completeData = await completeRes.json()
|
||||
|
||||
if (completeData.code !== 200) {
|
||||
if (completeData.code === 502 || completeData.code === 526) {
|
||||
console.warn(`完成接口返回 ${completeData.code},可能为暂时性错误`, completeData)
|
||||
throw new Error(
|
||||
getLocaleText('saveMaybeFailed', completeData.code)
|
||||
)
|
||||
} else {
|
||||
throw new Error(getLocaleText('publishFailed', completeData.msg || completeData.code))
|
||||
}
|
||||
}
|
||||
|
||||
uploadProgress.value = 100
|
||||
return completeData
|
||||
}
|
||||
|
||||
// 开始上传流程
|
||||
const startUpload = async () => {
|
||||
if (uploading.value) return
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
uploadStatus.value = locale.value.statusPreparing
|
||||
uploadMessage.value = ''
|
||||
|
||||
try {
|
||||
// 兼容不同来源的歌曲标识字段
|
||||
const musicId =
|
||||
props.song.strMediaMid ||
|
||||
props.song.songmid ||
|
||||
props.song.songId ||
|
||||
props.song.musicId ||
|
||||
props.song.id ||
|
||||
props.song.mid
|
||||
|
||||
if (!musicId) {
|
||||
console.error('所有可能的ID字段都为空')
|
||||
throw new Error(locale.value.missingResolvedId)
|
||||
}
|
||||
|
||||
// 获取来源歌曲下载链接
|
||||
uploadMessage.value = locale.value.fetchingQQUrl
|
||||
|
||||
const musicUrl = await getQQMusicUrl(musicId, selectedQuality.value)
|
||||
|
||||
if (!musicUrl) {
|
||||
throw new Error(locale.value.missingMusicUrl)
|
||||
}
|
||||
|
||||
// 下载音频文件
|
||||
uploadMessage.value = locale.value.downloadingAudio
|
||||
const { blob: audioBlob, ext } = await downloadAudio(musicUrl)
|
||||
|
||||
// 上传到网易云音乐云盘
|
||||
uploadMessage.value = locale.value.uploadingCloud
|
||||
const filename = `${artistName.value} - ${songName.value}.${ext}`
|
||||
await uploadToNetease(audioBlob, filename)
|
||||
|
||||
uploadProgress.value = 100
|
||||
uploadStatus.value = locale.value.statusDone
|
||||
uploadMessage.value = locale.value.uploadSuccess
|
||||
|
||||
showSuccess(locale.value.uploadSuccess)
|
||||
|
||||
emit('upload-success')
|
||||
|
||||
// 成功后延迟关闭对话框
|
||||
setTimeout(() => {
|
||||
closeDialog()
|
||||
}, 1500)
|
||||
} catch (error: any) {
|
||||
console.error('上传失败:', error)
|
||||
uploadStatus.value = locale.value.statusFailed
|
||||
uploadMessage.value = getErrorMessage(error) || locale.value.unknownError
|
||||
|
||||
showError(getLocaleText('uploadFailed', getErrorMessage(error)))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--overlay-10);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--overlay-20);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user