feat(ui06): B2 shared components + B3a core pages — album cards, photo grid, viewer, summary header

- 类型补齐契约字段:Summary.cover / AdminPhoto.preview(SignedUrl;服务端已返回,B0 核对)
- AlbumCoverCard:封面卡(公开/状态/上传 pill,无封面中性占位,不显示 owner 名)
- 相册列表:默认封面卡片网格(3列/1700+4列)+ 表格切换 + 已加载内搜索 + 公开状态筛选 + 摘要行
- AlbumSummaryHeader:300×144 封面 + 两列紧凑资料 + 高级折叠(ownerId/统计时间收敛)
- 详情页壳:页头(编辑/分享固定主操作)替换 ElDescriptions 大区;banner 区 B3c 收纳
- PhotoGrid + PhotoPreviewDialog:照片网格默认(preview.thumb 签名 URL)+ 深色大图(browse URL、
  行内隐藏/恢复/软删除复用表格状态机);网格勾选与表格 selection 合并批量目标
- e2e 适配默认网格视图(albums/governance 断言前切表格);batchHide fixture 补 preview
- typecheck/lint/vitest 87 项全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 23:19:21 +08:00
parent cebe3d1e2e
commit 4f6790d630
13 changed files with 1767 additions and 215 deletions
@@ -0,0 +1,206 @@
<!-- 相册封面卡片(UI06:admin-preview 示例 02 .album 结构)
封面 16:9 区域 + 状态 pill + 标题/日期地点 + 底部状态行。
契约约束:不显示 owner 显示名(无投影、避免 N+1);无封面用中性占位。 -->
<template>
<article class="ui06-album-card" :data-test="'album-card-' + album.id">
<button
type="button"
class="album-cover"
:data-test="'album-cover-' + album.id"
:aria-label="titleOf(album)"
@click="$emit('open', album)"
>
<img v-if="album.cover?.url" :src="album.cover.url" alt="" loading="lazy" />
<span v-else class="cover-placeholder">
<ElIcon :size="26"><Picture /></ElIcon>
</span>
<span
class="pill"
:class="album.publicEnabled ? 'green' : 'gray'"
data-test="album-pill-public"
>
{{ album.publicEnabled ? t('albumsManage.card.public') : t('albumsManage.card.private') }}
</span>
</button>
<div class="album-info">
<h2>
<button type="button" class="title-btn" @click="$emit('open', album)">
{{ titleOf(album) }}
</button>
</h2>
<p class="meta">{{ metaLine(album) }}</p>
<div class="album-footer">
<span
class="pill"
:class="album.status === 'active' ? 'green' : 'gray'"
:data-test="'album-pill-status-' + album.id"
>
{{
album.status === 'active'
? t('albumsManage.card.active')
: t('albumsManage.card.offline')
}}
</span>
<span
class="pill"
:class="album.uploadEnabled ? '' : 'gray'"
:data-test="'album-pill-upload-' + album.id"
>
{{
album.uploadEnabled
? t('albumsManage.card.uploadsOn')
: t('albumsManage.card.uploadsOff')
}}
</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { Picture } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
defineOptions({ name: 'AlbumCoverCard' })
const props = defineProps<{
album: Api.Album.Summary
/** 标题解析(defaultLocale 优先,回退首条翻译) */
titleOf: (album: Api.Album.Summary) => string
/** 日期 · 地点行 */
metaOf?: (album: Api.Album.Summary) => string
}>()
defineEmits<{
(e: 'open', album: Api.Album.Summary): void
}>()
const { t } = useI18n()
function metaLine(album: Api.Album.Summary): string {
if (props.metaOf) return props.metaOf(album)
const venue = album.translations.find((x) => x.locale === album.defaultLocale)?.venue
const date = album.startAt ? album.startAt.slice(0, 10) : ''
return [date, venue].filter(Boolean).join(' · ')
}
</script>
<style lang="scss" scoped>
/* 对齐示例 .album / .album-cover / .album-info / .album-footer */
.ui06-album-card {
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
border: 1px solid #e5eaf1;
border-radius: 12px;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 10px 25px #243e6710;
}
}
.album-cover {
position: relative;
display: block;
width: 100%;
height: 188px;
padding: 0;
overflow: hidden;
cursor: pointer;
background: #dce5ef; /* 示例 .art 占位底 */
border: 0;
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.cover-placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #9fb0c4;
}
.pill {
position: absolute;
top: 12px;
left: 12px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
font-size: 10px;
color: #5676a5;
letter-spacing: 0.2px;
background: #edf3fd;
border-radius: 6px;
white-space: nowrap;
&.green {
color: #188761;
background: #e9f7f1;
}
&.gray {
color: #8b96a7;
background: #f0f2f6;
}
}
.album-info {
display: flex;
flex-direction: column;
flex: 1;
padding: 18px;
h2 {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
.title-btn {
padding: 0;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
background: none;
border: 0;
&:hover {
color: var(--el-color-primary);
}
}
}
.meta {
margin-top: 7px;
margin-bottom: 14px;
font-size: 11px;
color: #7b899f;
}
}
.album-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 13px;
margin-top: auto;
border-top: 1px solid #edf1f5;
.pill {
position: static;
}
}
</style>
@@ -0,0 +1,306 @@
<!-- 相册详情摘要头(UI06:示例 03 .album-banner——封面 + 两列紧凑资料 + 固定主操作)
契约约束:不显示 owner 显示名;内部 ID / 统计时间等收进「高级信息」折叠。 -->
<template>
<div class="ui06-summary card">
<!-- 封面 300×144(无封面中性占位) -->
<div class="cover" data-test="album-summary-cover">
<img v-if="album.cover?.url" :src="album.cover.url" alt="" />
<span v-else class="cover-placeholder">
<ElIcon :size="26"><Picture /></ElIcon>
</span>
</div>
<!-- 两列紧凑资料(示例 .info-columns .meta-item) -->
<div class="info-columns">
<div class="meta-item">
<ElIcon><Calendar /></ElIcon>
<div>
<strong>{{ dateRange }}</strong>
<p>{{ $t('albumsManage.summary.eventTimezone', { tz: album.timezone }) }}</p>
</div>
</div>
<div class="meta-item">
<ElIcon><Location /></ElIcon>
<div>
<strong>{{ venue || $t('albumsManage.summary.venueUnset') }}</strong>
<p>{{ $t('albumsManage.summary.venueLabel') }}</p>
</div>
</div>
<div class="meta-item">
<ElIcon><Promotion /></ElIcon>
<div>
<strong>
{{
album.publicEnabled
? $t('albumsManage.summary.publicOn')
: $t('albumsManage.summary.publicOff')
}}
</strong>
<p>
{{
album.uploadEnabled
? $t('albumsManage.summary.uploadOn')
: $t('albumsManage.summary.uploadOff')
}}
</p>
</div>
</div>
<div class="meta-item">
<ElIcon><Link /></ElIcon>
<div>
<strong>
<span class="token" data-test="album-detail-token">{{ album.publicToken }}</span>
<ElButton link type="primary" size="small" @click="copyToken">
<ElIcon><CopyDocument /></ElIcon>
</ElButton>
</strong>
<p>
<ElTag size="small" :type="album.status === 'active' ? 'success' : 'info'">
{{
album.status === 'active'
? $t('albumsManage.card.active')
: $t('albumsManage.card.offline')
}}
</ElTag>
</p>
</div>
</div>
</div>
</div>
<!-- 高级信息折叠(内部 ID / 同步统计;默认收起) -->
<ElCollapse class="ui06-advanced">
<ElCollapseItem :title="$t('albumsManage.summary.advanced')" name="advanced">
<div class="advanced-grid">
<span
>{{ $t('albumsManage.detail.fieldOwnerId') }}:<span data-test="album-detail-owner-id">{{
album.ownerId
}}</span></span
>
<span>
{{ $t('albumsManage.detail.fieldStatsSyncedAt') }}:
<span data-test="album-detail-stats-synced-at">{{
album.statsSyncedAt
? formatDateTime(album.statsSyncedAt)
: $t('albumsManage.detail.statsNotSynced')
}}</span>
</span>
<span
>{{ $t('albumsManage.detail.fieldCreatedAt') }}:{{
formatDateTime(album.createdAt)
}}</span
>
<span
>{{ $t('albumsManage.detail.fieldUpdatedAt') }}:{{
formatDateTime(album.updatedAt)
}}</span
>
<span>
{{ $t('albumsManage.detail.fieldSwitches') }}:
<ElTag
v-for="sw in switchTags"
:key="sw.key"
size="small"
:type="sw.on ? 'success' : 'info'"
:effect="sw.on ? 'light' : 'plain'"
>
{{ sw.label }} {{ sw.on ? onLabel : offLabel }}
</ElTag>
</span>
</div>
</ElCollapseItem>
</ElCollapse>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Calendar,
Location,
Promotion,
Link,
CopyDocument,
Picture
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { formatDateTime } from '@/utils/albums/format'
defineOptions({ name: 'AlbumSummaryHeader' })
const props = defineProps<{
album: Api.Album.Summary
}>()
const { t } = useI18n()
const dateRange = computed(() => {
const start = props.album.startAt?.slice(0, 10) ?? ''
const end = props.album.endAt?.slice(0, 10)
return end ? `${start} – ${end}` : start
})
const venue = computed(
() => props.album.translations.find((x) => x.locale === props.album.defaultLocale)?.venue ?? ''
)
const onLabel = computed(() => t('albumsManage.list.on'))
const offLabel = computed(() => t('albumsManage.list.off'))
const switchTags = computed(() => [
{ key: 'upload', on: props.album.uploadEnabled, label: t('albumsManage.list.switchUpload') },
{ key: 'public', on: props.album.publicEnabled, label: t('albumsManage.list.switchPublic') },
{
key: 'download',
on: props.album.downloadEnabled,
label: t('albumsManage.list.switchDownload')
},
{
key: 'original',
on: props.album.originalDownloadEnabled,
label: t('albumsManage.list.switchOriginal')
}
])
async function copyToken() {
await navigator.clipboard.writeText(props.album.publicToken)
ElMessage.success(t('albumsManage.list.copied'))
}
</script>
<style lang="scss" scoped>
@reference '@styles/core/tailwind.css';
/* 对齐示例 .album-banner / .info-columns / .meta-item */
.ui06-summary {
display: flex;
gap: 26px;
align-items: center;
padding: 14px;
}
.card {
background: #ffffff;
border: 1px solid #e5eaf1;
border-radius: 12px;
}
.cover {
position: relative;
flex: none;
width: 300px;
height: 144px;
overflow: hidden;
background: #dce5ef;
border-radius: 8px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.cover-placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #9fb0c4;
}
.info-columns {
display: grid;
flex: 1;
grid-template-columns: 1fr 1fr;
gap: 22px 20px;
}
.meta-item {
display: flex;
gap: 11px;
align-items: flex-start;
color: #7e8fa7;
.el-icon {
margin-top: 2px;
color: #9fb0c4;
}
strong {
display: block;
margin-bottom: 3px;
font-size: 12px;
font-weight: 550;
color: #35455e;
.token {
font-family: ui-monospace, Consolas, monospace;
font-size: 11px;
}
}
p {
font-size: 11px;
}
}
.ui06-advanced {
margin-top: 14px;
border: 0;
:deep(.el-collapse-item__header) {
font-size: 12px;
color: #7b899f;
background: transparent;
border: 0;
}
:deep(.el-collapse-item__wrap) {
background: transparent;
border: 0;
}
.advanced-grid {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: #52617a;
}
}
@media (max-width: 900px) {
.ui06-summary {
align-items: flex-start;
gap: 15px;
}
.cover {
width: 180px;
height: 150px;
}
.info-columns {
grid-template-columns: 1fr;
gap: 14px;
}
}
@media (max-width: 640px) {
.ui06-summary {
display: block;
}
.cover {
width: 100%;
height: 140px;
margin-bottom: 15px;
}
.info-columns {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
}
</style>
@@ -0,0 +1,271 @@
<!-- 照片网格(UI06:示例 03 .photo-grid——四列、缩略图、选择框、状态角标、说明行)
契约:缩略图用 AdminPhoto.preview.thumb 签名 URL(可空=无预览占位);
仅 published 可勾选(批量隐藏前置);无原文件名字段,回退显示 photoId 短码。 -->
<template>
<div class="ui06-photo-grid" data-test="photo-grid">
<article v-for="photo in photos" :key="photo.photoId" class="photo-item">
<div class="photo-image" :class="{ selected: selectedIds.has(photo.photoId) }">
<button
type="button"
class="photo-open"
:aria-label="t('albumsManage.photosGrid.openAria')"
@click="$emit('open', photo)"
>
<img
v-if="photo.preview?.thumb?.url"
:src="photo.preview.thumb.url"
alt=""
loading="lazy"
/>
<span v-else class="thumb-placeholder">
<ElIcon :size="22"><Picture /></ElIcon>
<span>{{ noPreviewLabel(photo) }}</span>
</span>
</button>
<input
class="photo-check"
type="checkbox"
:checked="selectedIds.has(photo.photoId)"
:disabled="photo.visibility !== 'published'"
:aria-label="t('albumsManage.photosGrid.selectAria')"
:data-test="`photo-check-${photo.photoId}`"
@change="$emit('toggle', photo.photoId)"
/>
<span class="pill" :class="pillClass(photo.visibility)">
{{ t(`albumsManage.photos.visibility${pascal(photo.visibility)}`) }}
</span>
</div>
<div class="photo-caption">
<div class="caption-text">
<strong class="mono">{{ photo.photoId.slice(-8) }}</strong>
<p>{{ uploaderOf(photo) }} · {{ formatDate(photo.uploadedAt) }}</p>
</div>
<button
type="button"
class="caption-more"
:aria-label="t('albumsManage.photosGrid.openAria')"
@click="$emit('open', photo)"
>
<ElIcon><MoreFilled /></ElIcon>
</button>
</div>
</article>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Picture, MoreFilled } from '@element-plus/icons-vue'
defineOptions({ name: 'PhotoGrid' })
defineProps<{
photos: Api.Album.AdminPhoto[]
selectedIds: Set<string>
}>()
defineEmits<{
(e: 'open', photo: Api.Album.AdminPhoto): void
(e: 'toggle', photoId: string): void
}>()
const { t } = useI18n()
function pascal(value: string): string {
return value
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
function pillClass(visibility: Api.Album.PhotoVisibility): string {
switch (visibility) {
case 'published':
return 'green'
case 'hidden':
return 'amber'
case 'deleted':
return 'red'
default:
return 'gray'
}
}
function uploaderOf(photo: Api.Album.AdminPhoto): string {
return photo.uploader.displayName || photo.uploader.account
}
function formatDate(value: string): string {
return value.slice(0, 10)
}
function noPreviewLabel(photo: Api.Album.AdminPhoto): string {
if (photo.processingState === 'processing' || photo.processingState === 'pending_upload') {
return t('albumsManage.photos.stateProcessing')
}
if (photo.processingState === 'processing_failed') {
return t('albumsManage.photos.stateProcessingFailed')
}
return t('albumsManage.photosGrid.noPreview')
}
</script>
<style lang="scss" scoped>
/* 对齐示例 .photo-grid / .photo-item / .photo-image / .photo-caption */
.ui06-photo-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px 16px;
}
.photo-item {
min-width: 0;
}
.photo-image {
position: relative;
aspect-ratio: 1.65;
overflow: hidden;
background: #dce6ef;
border: 2px solid transparent;
border-radius: 8px;
}
.photo-open {
display: block;
width: 100%;
height: 100%;
padding: 0;
cursor: pointer;
background: none;
border: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.thumb-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: center;
font-size: 10px;
color: #7e93ab;
}
.photo-image.selected {
border-color: var(--el-color-primary);
}
.photo-check {
position: absolute;
top: 9px;
left: 9px;
z-index: 2;
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--el-color-primary);
&:disabled {
cursor: not-allowed;
opacity: 0.35;
}
}
.pill {
position: absolute;
top: 7px;
right: 7px;
padding: 3px 7px;
font-size: 10px;
color: #5676a5;
background: #edf3fdcc;
border-radius: 6px;
backdrop-filter: blur(2px);
&.green {
color: #188761;
background: #e9f7f1dd;
}
&.amber {
color: #ae7c27;
background: #fff5e5dd;
}
&.red {
color: #d75168;
background: #fceef1dd;
}
&.gray {
color: #8b96a7;
background: #f0f2f6dd;
}
}
.photo-caption {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 9px 2px 0;
.caption-text {
min-width: 0;
strong {
font-size: 11px;
font-weight: 550;
}
p {
margin-top: 3px;
font-size: 10px;
color: #8a97ac;
}
}
.caption-more {
flex: none;
padding: 4px;
cursor: pointer;
color: #8a97ac;
background: none;
border: 0;
border-radius: 6px;
&:hover {
color: var(--el-color-primary);
background: #edf3fd;
}
}
}
.mono {
font-family: ui-monospace, Consolas, monospace;
}
@media (max-width: 1200px) {
.ui06-photo-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.ui06-photo-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 8px;
}
.photo-image {
aspect-ratio: 1.2;
}
}
</style>
@@ -0,0 +1,293 @@
<!-- 管理照片大图(UI06:示例 16 .viewer-dialog——深色舞台 + 右侧信息栏 + 行内动作)
契约:browse 签名 URL 可空(无预览/处理中/无权限真实空态);动作语义与表格一致。 -->
<template>
<ElDialog
:model-value="visible"
:show-close="false"
width="1050px"
class="ui06-viewer-dialog"
data-test="photo-viewer-dialog"
@update:model-value="$emit('close')"
>
<div v-if="photo" class="viewer-body">
<!-- 深色舞台 -->
<div class="viewer-main">
<img v-if="photo.preview?.browse?.url" :src="photo.preview.browse.url" alt="" />
<div v-else class="viewer-empty">
<ElIcon :size="34"><Picture /></ElIcon>
<span>{{ emptyLabel }}</span>
</div>
<button
type="button"
class="viewer-close"
:aria-label="t('common.close')"
@click="$emit('close')"
>
<ElIcon :size="20"><Close /></ElIcon>
</button>
</div>
<!-- 信息栏 -->
<div class="viewer-info">
<div class="info-row">
<span class="label">{{ t('albumsManage.photos.colVisibility') }}</span>
<ElTag size="small" :type="visibilityTagType" effect="light">
{{ t(`albumsManage.photos.visibility${pascal(photo.visibility)}`) }}
</ElTag>
</div>
<div class="info-row">
<span class="label">{{ t('albumsManage.photos.colState') }}</span>
<ElTag size="small" :type="stateTagType" effect="light">
{{ t(`albumsManage.photos.state${pascal(photo.processingState)}`) }}
</ElTag>
</div>
<div class="info-row">
<span class="label">{{ t('albumsManage.photosGrid.uploader') }}</span>
<span>{{ photo.uploader.displayName || photo.uploader.account }}</span>
</div>
<div class="info-row">
<span class="label">{{ t('albumsManage.photos.colUploadedAt') }}</span>
<span>{{ formatDateTime(photo.uploadedAt) }}</span>
</div>
<div class="info-row">
<span class="label">{{ t('albumsManage.photos.colSize') }}</span>
<span>
{{ photo.width && photo.height ? `${photo.width}×${photo.height}` : '—' }}
· {{ formatBytes(photo.bytes) }}
</span>
</div>
<div class="info-row">
<span class="label">{{ t('albumsManage.photos.colLikeCount') }}</span>
<span>{{ photo.likeCount }}</span>
</div>
<div class="info-row">
<span class="label">ID</span>
<span class="mono break-all">{{ photo.photoId }}</span>
</div>
<div class="actions">
<ElButton
v-if="photo.visibility === 'published'"
class="action-btn"
data-test="viewer-hide-btn"
@click="$emit('hide', photo)"
>
{{ t('albumsManage.photos.hide') }}
</ElButton>
<ElButton
v-if="photo.visibility === 'hidden'"
class="action-btn"
data-test="viewer-restore-btn"
@click="$emit('restore', photo)"
>
{{ t('albumsManage.photos.restore') }}
</ElButton>
<ElButton
v-if="photo.visibility !== 'deleted'"
class="action-btn danger"
data-test="viewer-delete-btn"
@click="$emit('remove', photo)"
>
{{ t('albumsManage.photos.delete') }}
</ElButton>
</div>
</div>
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Picture, Close } from '@element-plus/icons-vue'
import { formatBytes, formatDateTime } from '@/utils/albums/format'
defineOptions({ name: 'PhotoPreviewDialog' })
const props = defineProps<{
visible: boolean
photo: Api.Album.AdminPhoto | null
}>()
defineEmits<{
(e: 'close'): void
(e: 'hide', photo: Api.Album.AdminPhoto): void
(e: 'restore', photo: Api.Album.AdminPhoto): void
(e: 'remove', photo: Api.Album.AdminPhoto): void
}>()
const { t } = useI18n()
function pascal(value: string): string {
return value
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
const visibilityTagType = computed(() => {
switch (props.photo?.visibility) {
case 'published':
return 'success'
case 'hidden':
return 'warning'
case 'deleted':
return 'danger'
default:
return 'info'
}
})
const stateTagType = computed(() => {
switch (props.photo?.processingState) {
case 'ready':
return 'success'
case 'processing':
return 'warning'
case 'processing_failed':
return 'danger'
default:
return 'info'
}
})
const emptyLabel = computed(() => {
if (!props.photo) return ''
if (
props.photo.processingState === 'processing' ||
props.photo.processingState === 'pending_upload'
) {
return t('albumsManage.photos.stateProcessing')
}
if (props.photo.processingState === 'processing_failed') {
return t('albumsManage.photos.stateProcessingFailed')
}
return t('albumsManage.photosGrid.noPreview')
})
</script>
<style lang="scss" scoped>
/* 对齐示例 .viewer-dialog / .viewer-main / .viewer-info */
.viewer-body {
display: flex;
overflow: hidden;
background: #111925;
border-radius: 16px;
}
.viewer-main {
position: relative;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 520px;
img {
width: 85%;
max-height: 640px;
object-fit: contain;
}
}
.viewer-empty {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
color: #5c6c80;
}
.viewer-close {
position: absolute;
top: 12px;
left: 12px;
z-index: 3;
padding: 8px;
cursor: pointer;
color: #ffffff;
background: #ffffff1a;
border: 0;
border-radius: 50%;
&:hover {
background: #ffffff33;
}
}
.viewer-info {
flex: none;
width: 270px;
padding: 25px;
background: #17212f;
.info-row {
display: flex;
gap: 10px;
align-items: baseline;
justify-content: space-between;
margin-bottom: 13px;
font-size: 12px;
color: #d5dcea;
.label {
flex: none;
color: #a2afc1;
}
}
.actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid #263243;
.action-btn {
width: 100%;
color: #d5dcea;
background: transparent;
border: 1px solid #3a4a61;
&:hover {
color: #ffffff;
background: #223145;
border-color: #4a5d7a;
}
&.danger {
color: #ff9aa5;
&:hover {
color: #ffb3bb;
background: #3a2230;
border-color: #7a3a4a;
}
}
}
}
}
.mono {
font-family: ui-monospace, Consolas, monospace;
font-size: 11px;
}
@media (max-width: 640px) {
.viewer-body {
flex-direction: column;
max-height: 90vh;
overflow: auto;
}
.viewer-main {
min-height: 250px;
}
.viewer-info {
width: 100%;
}
}
</style>