569 lines
14 KiB
Vue
569 lines
14 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="market-page">
|
|||
|
|
<div class="market-shell">
|
|||
|
|
<header class="market-header">
|
|||
|
|
<div>
|
|||
|
|
<h1 class="title">资讯广场</h1>
|
|||
|
|
<p class="desc">展示绑定到菜单「资讯广场」的文章,支持搜索与标签筛选</p>
|
|||
|
|
</div>
|
|||
|
|
<button class="btn" type="button" @click="() => loadArticles(true)" :disabled="loading">
|
|||
|
|
{{ loading ? '加载中...' : '刷新' }}
|
|||
|
|
</button>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<section class="toolbar">
|
|||
|
|
<div class="toolbar-left">
|
|||
|
|
<div class="input-shell">
|
|||
|
|
<input
|
|||
|
|
v-model.trim="keyword"
|
|||
|
|
class="input"
|
|||
|
|
type="search"
|
|||
|
|
placeholder="搜索标题/描述/作者..."
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div class="chips-filter">
|
|||
|
|
<button
|
|||
|
|
v-for="tag in availableTags"
|
|||
|
|
:key="tag || 'all'"
|
|||
|
|
type="button"
|
|||
|
|
class="chip"
|
|||
|
|
:class="{ active: selectedTag === tag }"
|
|||
|
|
@click="selectedTag = tag"
|
|||
|
|
>
|
|||
|
|
{{ tag || '全部' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbar-right">
|
|||
|
|
<div class="stat">
|
|||
|
|
<div class="stat-label">文章</div>
|
|||
|
|
<div class="stat-value">{{ filteredArticles.length }}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="stat">
|
|||
|
|
<div class="stat-label">标签数</div>
|
|||
|
|
<div class="stat-value">{{ availableTags.length - 1 }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<div v-if="loading" class="state">加载中...</div>
|
|||
|
|
<div v-else-if="error" class="state error">加载失败:{{ error }}</div>
|
|||
|
|
<div v-else-if="filteredArticles.length === 0" class="state">暂无文章</div>
|
|||
|
|
<div v-else class="cards-grid">
|
|||
|
|
<div v-for="it in filteredArticles" :key="it.slug" class="card-wrap">
|
|||
|
|
<MarketCard
|
|||
|
|
:cover="it.cover || defaultCover"
|
|||
|
|
:title="it.title || '未命名文章'"
|
|||
|
|
:tags="it.tagList || []"
|
|||
|
|
:created-at="it.createdAt"
|
|||
|
|
:owner-name="it.author?.username || '官方'"
|
|||
|
|
:owner-avatar="it.author?.image || ''"
|
|||
|
|
:views="it.views ?? 0"
|
|||
|
|
:likes="getLikes(it)"
|
|||
|
|
:favorited="!!it.favorited"
|
|||
|
|
:visit-href="''"
|
|||
|
|
:detail-href="`/articles/${it.slug}`"
|
|||
|
|
@toggle-like="() => toggleFavorite(it)"
|
|||
|
|
@view="() => handleViewed(it)"
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
v-if="isAdmin || it.isTop"
|
|||
|
|
class="fire-btn"
|
|||
|
|
:class="{ active: it.isTop, readonly: !isAdmin }"
|
|||
|
|
:disabled="!isAdmin || savingMap[it.slug]"
|
|||
|
|
@click.stop="isAdmin ? toggleTop(it) : undefined"
|
|||
|
|
aria-label="置顶"
|
|||
|
|
>
|
|||
|
|
<svg viewBox="0 0 1024 1024" aria-hidden="true">
|
|||
|
|
<path
|
|||
|
|
d="M437.376 42.667c232.277 92.587 240 364.245 240.256 382.293v.896l72.107-71.85c325.973 401.365 24.576 573.952-110.507 627.328 1.792-3.413 3.499-6.997 5.248-10.709 21.376-45.44 33.237-91.776 29.995-137.088a173.355 173.355 0 0 0-59.734-120.576l-8.192-6.912c-40.192-32-64-65.024-75.477-97.621a119.168 119.168 0 0 1-7.424-46.763l.683-6.485 23.765-94.848-83.627 51.285c-151.381 92.885-201.387 213.547-171.179 342.485 10.155 43.435 28.715 84.395 52.48 122.027-184.917-78.379-225.963-201.941-216.491-322.432 7.979-101.931 75.392-182.016 146.432-257.408l17.792-18.773C391.509 274.688 487.723 177.92 437.376 42.667Z"
|
|||
|
|
:fill="it.isTop ? '#ee3921' : '#cdcdcd'"
|
|||
|
|
/>
|
|||
|
|
<path
|
|||
|
|
d="M449.92 620.16c13.568 47.957 46.507 98.389 105.984 145.792 24.363 19.413 36.01 42.667 38.016 70.827 2.133 29.483-6.485 63.317-22.613 97.493l-5.291 10.752-5.461 10.155-3.669 6.187-5.291.597c-16.64 1.707-35.797 2.731-57.216 2.731-21.291 0-39.467-1.024-54.485-2.688l-5.845-.725c-7.531-8.789-14.613-18.86-20.651-29.227-30.272-52.523-45.632-86.923-53.739-121.398-18.304-78.208 2.219-149.931 77.056-214.4Z"
|
|||
|
|
:fill="it.isTop ? '#ee3921' : '#cdcdcd'"
|
|||
|
|
:opacity="it.isTop ? '0.5' : '0.5'"
|
|||
|
|
/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div ref="sentinelRef" class="sentinel">
|
|||
|
|
<span v-if="loadingMore">正在加载更多...</span>
|
|||
|
|
<span v-else-if="!hasMore">已到底部</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
|||
|
|
import { useApi, useAuthToken } from '@/composables/useApi'
|
|||
|
|
import { useAuth } from '@/composables/useAuth'
|
|||
|
|
import MarketCard from '../components/home/MarketCard.vue'
|
|||
|
|
import { useArticleNavContext } from '@/composables/useArticleNavContext'
|
|||
|
|
|
|||
|
|
interface ArticleItem {
|
|||
|
|
slug: string
|
|||
|
|
title: string
|
|||
|
|
description?: string | null
|
|||
|
|
cover?: string | null
|
|||
|
|
tagList?: string[]
|
|||
|
|
isTop?: boolean
|
|||
|
|
isFeatured?: boolean
|
|||
|
|
sortWeight?: number
|
|||
|
|
createdAt?: string
|
|||
|
|
views?: number
|
|||
|
|
favorited?: boolean
|
|||
|
|
favoritesCount?: number
|
|||
|
|
favorites_count?: number
|
|||
|
|
author?: {
|
|||
|
|
username?: string
|
|||
|
|
image?: string | null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ArticlesResponse {
|
|||
|
|
articles?: ArticleItem[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const api = useApi()
|
|||
|
|
const { user: authUser, fetchMe } = useAuth() as any
|
|||
|
|
const { token } = useAuthToken()
|
|||
|
|
const navCtx = useArticleNavContext()
|
|||
|
|
const articles = ref<ArticleItem[]>([])
|
|||
|
|
const loading = ref(false)
|
|||
|
|
const loadingMore = ref(false)
|
|||
|
|
const error = ref('')
|
|||
|
|
const defaultCover = '/cover.jpg'
|
|||
|
|
const keyword = ref('')
|
|||
|
|
const selectedTag = ref<string | null>(null)
|
|||
|
|
const offset = ref(0)
|
|||
|
|
const hasMore = ref(true)
|
|||
|
|
const currentMode = ref<'and' | 'or'>('and')
|
|||
|
|
const PAGE_SIZE = 20
|
|||
|
|
const sentinelRef = ref<HTMLElement | null>(null)
|
|||
|
|
let observer: IntersectionObserver | null = null
|
|||
|
|
const savingMap = ref<Record<string, boolean>>({})
|
|||
|
|
|
|||
|
|
const isAdmin = computed(() => {
|
|||
|
|
const roles = authUser.value?.roles
|
|||
|
|
return Array.isArray(roles) && roles.includes('admin')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function getLikes(it: ArticleItem): number {
|
|||
|
|
return Number(it.favoritesCount ?? it.favorites_count ?? 0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleViewed(it: ArticleItem): void {
|
|||
|
|
it.views = (it.views ?? 0) + 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleFavorite(it: ArticleItem): Promise<void> {
|
|||
|
|
alert('请在文章详情页完成收藏操作')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadArticles(reset = false): Promise<void> {
|
|||
|
|
if (loading.value || loadingMore.value) return
|
|||
|
|
if (reset) {
|
|||
|
|
loading.value = true
|
|||
|
|
articles.value = []
|
|||
|
|
offset.value = 0
|
|||
|
|
hasMore.value = true
|
|||
|
|
currentMode.value = 'and'
|
|||
|
|
error.value = ''
|
|||
|
|
} else {
|
|||
|
|
if (!hasMore.value) return
|
|||
|
|
loadingMore.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = (await api.get('/articles/menu/news', {
|
|||
|
|
limit: PAGE_SIZE,
|
|||
|
|
offset: offset.value,
|
|||
|
|
mode: currentMode.value,
|
|||
|
|
})) as ArticlesResponse
|
|||
|
|
|
|||
|
|
let list = Array.isArray(res.articles) ? res.articles : []
|
|||
|
|
if (offset.value === 0 && currentMode.value === 'and' && list.length === 0) {
|
|||
|
|
currentMode.value = 'or'
|
|||
|
|
const orRes = (await api.get('/articles/menu/news', {
|
|||
|
|
limit: PAGE_SIZE,
|
|||
|
|
offset: 0,
|
|||
|
|
mode: currentMode.value,
|
|||
|
|
})) as ArticlesResponse
|
|||
|
|
list = Array.isArray(orRes.articles) ? orRes.articles : []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const merged = reset ? list : [...articles.value, ...list]
|
|||
|
|
articles.value = merged
|
|||
|
|
offset.value += list.length
|
|||
|
|
hasMore.value = list.length === PAGE_SIZE
|
|||
|
|
hydrateFilters()
|
|||
|
|
|
|||
|
|
navCtx.setList('market', merged.map((a) => a.slug).filter(Boolean))
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error('[Market] load articles failed', err)
|
|||
|
|
error.value = err?.statusMessage || err?.message || '加载失败'
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
loadingMore.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const availableTags = computed(() => {
|
|||
|
|
const set = new Set<string>()
|
|||
|
|
for (const a of articles.value) {
|
|||
|
|
for (const t of a.tagList || []) {
|
|||
|
|
const tag = String(t || '').trim()
|
|||
|
|
if (tag) set.add(tag)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const list = ['全部', ...Array.from(set)]
|
|||
|
|
return list.map((t) => (t === '全部' ? null : t)) as (string | null)[]
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const filteredArticles = computed(() => {
|
|||
|
|
const kw = keyword.value.toLowerCase()
|
|||
|
|
const list = articles.value.filter((a) => {
|
|||
|
|
const matchTag = selectedTag.value ? (a.tagList || []).includes(selectedTag.value) : true
|
|||
|
|
const matchKw = kw
|
|||
|
|
? [a.title, a.description, a.author?.username]
|
|||
|
|
.map((v) => (v || '').toLowerCase())
|
|||
|
|
.some((v) => v.includes(kw))
|
|||
|
|
: true
|
|||
|
|
return matchTag && matchKw
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return [...list].sort((a, b) => {
|
|||
|
|
const topA = a.isTop ? 1 : 0
|
|||
|
|
const topB = b.isTop ? 1 : 0
|
|||
|
|
if (topA !== topB) return topB - topA
|
|||
|
|
const wA = Number(a.sortWeight ?? 0)
|
|||
|
|
const wB = Number(b.sortWeight ?? 0)
|
|||
|
|
if (wA !== wB) return wB - wA
|
|||
|
|
const tA = new Date(a.createdAt || 0).getTime()
|
|||
|
|
const tB = new Date(b.createdAt || 0).getTime()
|
|||
|
|
return tB - tA
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function hydrateFilters(): void {
|
|||
|
|
if (!selectedTag.value && availableTags.value.length) {
|
|||
|
|
selectedTag.value = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function ensureUser(): Promise<void> {
|
|||
|
|
if (!token.value) return
|
|||
|
|
if (!authUser.value) {
|
|||
|
|
try {
|
|||
|
|
await fetchMe()
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('[Market] fetchMe failed', err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function updateFlags(
|
|||
|
|
article: ArticleItem,
|
|||
|
|
patch: { isTop?: boolean; isFeatured?: boolean; sortWeight?: number },
|
|||
|
|
): Promise<void> {
|
|||
|
|
if (!isAdmin.value || !article?.slug) return
|
|||
|
|
if (savingMap.value[article.slug]) return
|
|||
|
|
savingMap.value[article.slug] = true
|
|||
|
|
try {
|
|||
|
|
await api.put(`/admin/articles/${article.slug}`, { article: patch })
|
|||
|
|
Object.assign(article, patch)
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error('[Market] update flags failed', err)
|
|||
|
|
alert(err?.statusMessage || '更新失败')
|
|||
|
|
} finally {
|
|||
|
|
delete savingMap.value[article.slug]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleTop(article: ArticleItem): Promise<void> {
|
|||
|
|
const next = !article.isTop
|
|||
|
|
const sortWeight = next ? Math.floor(Date.now() / 1000) : 0
|
|||
|
|
await updateFlags(article, { isTop: next, sortWeight })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleFeatured(article: ArticleItem): Promise<void> {
|
|||
|
|
const next = !article.isFeatured
|
|||
|
|
await updateFlags(article, { isFeatured: next })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setupObserver(): void {
|
|||
|
|
if (!('IntersectionObserver' in window)) return
|
|||
|
|
observer = new IntersectionObserver(
|
|||
|
|
(entries) => {
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
if (entry.isIntersecting) {
|
|||
|
|
loadArticles(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ rootMargin: '200px 0px 200px 0px', threshold: 0 },
|
|||
|
|
)
|
|||
|
|
if (sentinelRef.value) {
|
|||
|
|
observer.observe(sentinelRef.value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
await ensureUser()
|
|||
|
|
await loadArticles(true)
|
|||
|
|
setupObserver()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onBeforeUnmount(() => {
|
|||
|
|
if (observer) observer.disconnect()
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.market-page {
|
|||
|
|
margin-top: 5%;
|
|||
|
|
padding: 20px 0 32px;
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
background: var(--soft-page-bg);
|
|||
|
|
min-height: 100vh;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.market-shell {
|
|||
|
|
width: min(1200px, 96vw);
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.market-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title {
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 24px;
|
|||
|
|
font-weight: 800;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.desc {
|
|||
|
|
margin: 4px 0 0;
|
|||
|
|
color: #6b7280;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn {
|
|||
|
|
padding: 8px 14px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
border: 1px solid #d1d5db;
|
|||
|
|
background: #fff;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.18s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn:disabled {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbar {
|
|||
|
|
background: #fff;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
border-radius: 14px;
|
|||
|
|
padding: 10px 12px;
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 12px;
|
|||
|
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbar-left {
|
|||
|
|
display: flex;
|
|||
|
|
flex: 1 1 60%;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-shell {
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input {
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
border: 1px solid #d1d5db;
|
|||
|
|
min-width: 240px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
background: #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chips-filter {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chip {
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
background: #f9fafb;
|
|||
|
|
color: #374151;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
padding: 6px 12px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.15s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chip.active {
|
|||
|
|
background: #111827;
|
|||
|
|
color: #fff;
|
|||
|
|
border-color: #111827;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbar-right {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat {
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
background: #f8fafc;
|
|||
|
|
min-width: 90px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-label {
|
|||
|
|
color: #6b7280;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-value {
|
|||
|
|
font-size: 18px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: #0f172a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cards-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|||
|
|
gap: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-wrap {
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.admin-flags {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 8px;
|
|||
|
|
right: 8px;
|
|||
|
|
display: inline-flex;
|
|||
|
|
gap: 6px;
|
|||
|
|
z-index: 2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.fire-btn {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 8px;
|
|||
|
|
right: 8px;
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
background: #fff;
|
|||
|
|
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
cursor: pointer;
|
|||
|
|
padding: 4px;
|
|||
|
|
transition: all 0.15s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.fire-btn svg {
|
|||
|
|
width: 18px;
|
|||
|
|
height: 18px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.fire-btn.active {
|
|||
|
|
border-color: #ee3921;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.fire-btn.readonly {
|
|||
|
|
cursor: default;
|
|||
|
|
opacity: 0.9;
|
|||
|
|
box-shadow: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.flag-btn {
|
|||
|
|
padding: 4px 8px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
border: 1px solid #d1d5db;
|
|||
|
|
background: rgba(255, 255, 255, 0.92);
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 12px;
|
|||
|
|
transition: all 0.15s ease;
|
|||
|
|
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.flag-btn.active {
|
|||
|
|
background: #111827;
|
|||
|
|
color: #fff;
|
|||
|
|
border-color: #111827;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 1280px) {
|
|||
|
|
.cards-grid {
|
|||
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 1024px) {
|
|||
|
|
.cards-grid {
|
|||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 640px) {
|
|||
|
|
.cards-grid {
|
|||
|
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.state {
|
|||
|
|
padding: 28px 10px;
|
|||
|
|
text-align: center;
|
|||
|
|
color: #6b7280;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.state.error {
|
|||
|
|
color: #b91c1c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.sentinel {
|
|||
|
|
text-align: center;
|
|||
|
|
color: #9ca3af;
|
|||
|
|
padding: 12px 0;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 640px) {
|
|||
|
|
.toolbar {
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
.toolbar-right {
|
|||
|
|
width: 100%;
|
|||
|
|
justify-content: flex-start;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|