162abdc97c
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports) - Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier - Add root scripts: format, format:check, lint, lint:fix - Reformat 257 files across admin-web, ops-web, desktop-client, packages - Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters - Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex - Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
889 lines
22 KiB
Vue
889 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { CloseOutlined, CloudUploadOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
|
import { message } from 'ant-design-vue'
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import ImageLibraryPicker from '@/components/ImageLibraryPicker.vue'
|
|
import { articlesApi } from '@/lib/api'
|
|
import {
|
|
deriveCoverFileName,
|
|
getPrimaryCoverRequirement,
|
|
type PlatformCoverRequirement,
|
|
} from '@/lib/cover-requirements'
|
|
import { formatError } from '@/lib/errors'
|
|
import {
|
|
IMAGE_UPLOAD_ACCEPT,
|
|
useImageUploadProgress,
|
|
validateImageUploadFile,
|
|
} from '@/lib/image-webp'
|
|
import type { ImageAssetItem } from '@geo/shared-types'
|
|
|
|
type PickerUploadResult = {
|
|
url: string
|
|
fileName?: string | null
|
|
assetId?: number | null
|
|
}
|
|
|
|
const props = defineProps<{
|
|
open: boolean
|
|
articleId: number | null
|
|
platformIds: string[]
|
|
currentUrl?: string | null
|
|
currentFileName?: string | null
|
|
currentAssetId?: number | null
|
|
title?: string
|
|
localTabLabel?: string
|
|
emptyUploadTitle?: string
|
|
emptyUploadHint?: string
|
|
uploadHandler?: (file: File) => Promise<PickerUploadResult>
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:open': [value: boolean]
|
|
confirmed: [payload: { url: string; fileName: string; assetId?: number | null }]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const imageUploadProgress = useImageUploadProgress()
|
|
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
const activeTab = ref('local')
|
|
const workingUrl = ref('')
|
|
const workingFileName = ref('')
|
|
const sourceKind = ref<'remote' | 'local' | null>(null)
|
|
const naturalWidth = ref(0)
|
|
const naturalHeight = ref(0)
|
|
const zoom = ref(1)
|
|
const offsetX = ref(0)
|
|
const offsetY = ref(0)
|
|
const confirmLoading = ref(false)
|
|
const transformDirty = ref(false)
|
|
const localObjectUrl = ref('')
|
|
const localFile = ref<File | null>(null)
|
|
const selectedLibraryImageId = ref<number | null>(null)
|
|
|
|
const dragging = ref(false)
|
|
const dragStartX = ref(0)
|
|
const dragStartY = ref(0)
|
|
const dragOriginX = ref(0)
|
|
const dragOriginY = ref(0)
|
|
const imageLoadToken = ref(0)
|
|
|
|
const primaryRequirement = computed(() => getPrimaryCoverRequirement(props.platformIds))
|
|
const aspectRatioLocked = computed(() => Boolean(primaryRequirement.value.aspectRatio))
|
|
const modalTitle = computed(() => props.title?.trim() || t('coverPicker.title'))
|
|
const resolvedLocalTabLabel = computed(
|
|
() => props.localTabLabel?.trim() || t('coverPicker.tabs.local'),
|
|
)
|
|
const resolvedEmptyUploadTitle = computed(
|
|
() => props.emptyUploadTitle?.trim() || t('coverPicker.dropzoneTitle'),
|
|
)
|
|
const resolvedEmptyUploadHint = computed(
|
|
() => props.emptyUploadHint?.trim() || t('coverPicker.dropzoneHint'),
|
|
)
|
|
const localTabLabelDisplay = computed(() =>
|
|
workingUrl.value ? `${resolvedLocalTabLabel.value} (1)` : resolvedLocalTabLabel.value,
|
|
)
|
|
const hasImage = computed(() => Boolean(workingUrl.value))
|
|
const imageUploadBusy = computed(() => imageUploadProgress.visible)
|
|
const stageSize = computed(() => {
|
|
const ratio =
|
|
primaryRequirement.value.aspectRatio ||
|
|
(naturalWidth.value > 0 && naturalHeight.value > 0
|
|
? naturalWidth.value / naturalHeight.value
|
|
: 4 / 3)
|
|
const maxWidth = 720
|
|
const maxHeight = 420
|
|
|
|
let width = maxWidth
|
|
let height = width / ratio
|
|
|
|
if (height > maxHeight) {
|
|
height = maxHeight
|
|
width = height * ratio
|
|
}
|
|
|
|
return {
|
|
width: Math.round(width),
|
|
height: Math.round(height),
|
|
}
|
|
})
|
|
|
|
const displayMetrics = computed(() => {
|
|
if (!naturalWidth.value || !naturalHeight.value) {
|
|
return {
|
|
width: 0,
|
|
height: 0,
|
|
baseScale: 1,
|
|
}
|
|
}
|
|
|
|
const baseScale = Math.max(
|
|
stageSize.value.width / naturalWidth.value,
|
|
stageSize.value.height / naturalHeight.value,
|
|
)
|
|
const scaled = baseScale * zoom.value
|
|
|
|
return {
|
|
width: naturalWidth.value * scaled,
|
|
height: naturalHeight.value * scaled,
|
|
baseScale,
|
|
}
|
|
})
|
|
|
|
const stageImageStyle = computed(() => ({
|
|
width: `${displayMetrics.value.width}px`,
|
|
height: `${displayMetrics.value.height}px`,
|
|
transform: `translate(calc(-50% + ${offsetX.value}px), calc(-50% + ${offsetY.value}px))`,
|
|
}))
|
|
|
|
const stageFrameStyle = computed(() => ({
|
|
width: `${stageSize.value.width}px`,
|
|
height: `${stageSize.value.height}px`,
|
|
}))
|
|
|
|
watch(
|
|
() => props.open,
|
|
(open) => {
|
|
if (open) {
|
|
hydrateFromProps()
|
|
return
|
|
}
|
|
stopDragging()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(
|
|
() => [props.currentUrl, props.currentFileName],
|
|
() => {
|
|
if (props.open && sourceKind.value !== 'local') {
|
|
hydrateFromProps()
|
|
}
|
|
},
|
|
)
|
|
|
|
watch(
|
|
() => props.platformIds.join(','),
|
|
() => {
|
|
if (!props.open || !hasImage.value) {
|
|
return
|
|
}
|
|
resetTransform()
|
|
},
|
|
)
|
|
|
|
watch(
|
|
() => [
|
|
zoom.value,
|
|
naturalWidth.value,
|
|
naturalHeight.value,
|
|
stageSize.value.width,
|
|
stageSize.value.height,
|
|
],
|
|
() => {
|
|
clampOffsets()
|
|
},
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
stopDragging()
|
|
revokeLocalObjectUrl()
|
|
})
|
|
|
|
function hydrateFromProps(): void {
|
|
activeTab.value = 'local'
|
|
transformDirty.value = false
|
|
const nextUrl = String(props.currentUrl ?? '').trim()
|
|
const nextFileName = String(props.currentFileName ?? '').trim() || deriveCoverFileName(nextUrl)
|
|
|
|
workingUrl.value = nextUrl
|
|
workingFileName.value = nextFileName
|
|
sourceKind.value = nextUrl ? 'remote' : null
|
|
localFile.value = null
|
|
selectedLibraryImageId.value = props.currentAssetId ?? null
|
|
|
|
if (!nextUrl) {
|
|
resetStage()
|
|
return
|
|
}
|
|
|
|
void loadImageMeta(nextUrl)
|
|
}
|
|
|
|
function resetStage(): void {
|
|
naturalWidth.value = 0
|
|
naturalHeight.value = 0
|
|
resetTransform()
|
|
}
|
|
|
|
function resetTransform(): void {
|
|
zoom.value = 1
|
|
offsetX.value = 0
|
|
offsetY.value = 0
|
|
transformDirty.value = false
|
|
}
|
|
|
|
function clampOffsets(): void {
|
|
const maxX = Math.max(0, (displayMetrics.value.width - stageSize.value.width) / 2)
|
|
const maxY = Math.max(0, (displayMetrics.value.height - stageSize.value.height) / 2)
|
|
|
|
offsetX.value = clamp(offsetX.value, -maxX, maxX)
|
|
offsetY.value = clamp(offsetY.value, -maxY, maxY)
|
|
}
|
|
|
|
function markTransformDirty(): void {
|
|
transformDirty.value = zoom.value !== 1 || offsetX.value !== 0 || offsetY.value !== 0
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(Math.max(value, min), max)
|
|
}
|
|
|
|
async function loadImageMeta(url: string): Promise<void> {
|
|
const token = imageLoadToken.value + 1
|
|
imageLoadToken.value = token
|
|
|
|
try {
|
|
const image = await loadImageElement(url)
|
|
if (token !== imageLoadToken.value) {
|
|
return
|
|
}
|
|
|
|
naturalWidth.value = image.naturalWidth
|
|
naturalHeight.value = image.naturalHeight
|
|
resetTransform()
|
|
} catch {
|
|
if (token !== imageLoadToken.value) {
|
|
return
|
|
}
|
|
|
|
resetStage()
|
|
message.warning(t('coverPicker.messages.imageLoadFailed'))
|
|
}
|
|
}
|
|
|
|
function openFileDialog(): void {
|
|
if (imageUploadBusy.value) {
|
|
return
|
|
}
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
function handleFileChange(event: Event): void {
|
|
if (imageUploadBusy.value) {
|
|
;(event.target as HTMLInputElement).value = ''
|
|
return
|
|
}
|
|
|
|
const input = event.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
input.value = ''
|
|
|
|
if (!file) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
validateImageUploadFile(file)
|
|
} catch (error) {
|
|
message.warning(formatError(error))
|
|
return
|
|
}
|
|
|
|
revokeLocalObjectUrl()
|
|
const objectUrl = URL.createObjectURL(file)
|
|
localObjectUrl.value = objectUrl
|
|
workingUrl.value = objectUrl
|
|
workingFileName.value = file.name
|
|
sourceKind.value = 'local'
|
|
localFile.value = file
|
|
selectedLibraryImageId.value = null
|
|
transformDirty.value = false
|
|
void loadImageMeta(objectUrl)
|
|
}
|
|
|
|
function revokeLocalObjectUrl(): void {
|
|
if (!localObjectUrl.value) {
|
|
return
|
|
}
|
|
URL.revokeObjectURL(localObjectUrl.value)
|
|
localObjectUrl.value = ''
|
|
}
|
|
|
|
function handleStageMouseDown(event: MouseEvent): void {
|
|
if (!hasImage.value || confirmLoading.value) {
|
|
return
|
|
}
|
|
|
|
dragging.value = true
|
|
dragStartX.value = event.clientX
|
|
dragStartY.value = event.clientY
|
|
dragOriginX.value = offsetX.value
|
|
dragOriginY.value = offsetY.value
|
|
window.addEventListener('mousemove', handleWindowMouseMove)
|
|
window.addEventListener('mouseup', handleWindowMouseUp)
|
|
}
|
|
|
|
function handleWindowMouseMove(event: MouseEvent): void {
|
|
if (!dragging.value) {
|
|
return
|
|
}
|
|
|
|
offsetX.value = dragOriginX.value + event.clientX - dragStartX.value
|
|
offsetY.value = dragOriginY.value + event.clientY - dragStartY.value
|
|
clampOffsets()
|
|
markTransformDirty()
|
|
}
|
|
|
|
function handleWindowMouseUp(): void {
|
|
stopDragging()
|
|
}
|
|
|
|
function stopDragging(): void {
|
|
if (!dragging.value) {
|
|
return
|
|
}
|
|
|
|
dragging.value = false
|
|
window.removeEventListener('mousemove', handleWindowMouseMove)
|
|
window.removeEventListener('mouseup', handleWindowMouseUp)
|
|
}
|
|
|
|
function handleZoomChange(value: number | [number, number]): void {
|
|
const nextValue = Array.isArray(value) ? value[0] : value
|
|
zoom.value = clamp(nextValue, 1, 2.4)
|
|
clampOffsets()
|
|
markTransformDirty()
|
|
}
|
|
|
|
function handleClear(): void {
|
|
if (confirmLoading.value) {
|
|
return
|
|
}
|
|
|
|
workingUrl.value = ''
|
|
workingFileName.value = ''
|
|
sourceKind.value = null
|
|
localFile.value = null
|
|
selectedLibraryImageId.value = null
|
|
transformDirty.value = false
|
|
resetStage()
|
|
revokeLocalObjectUrl()
|
|
}
|
|
|
|
function handleLibrarySelect(image: ImageAssetItem): void {
|
|
activeTab.value = 'local'
|
|
workingUrl.value = image.url
|
|
workingFileName.value = image.name
|
|
sourceKind.value = 'remote'
|
|
selectedLibraryImageId.value = image.id
|
|
localFile.value = null
|
|
transformDirty.value = false
|
|
void loadImageMeta(image.url)
|
|
}
|
|
|
|
async function handleConfirm(): Promise<void> {
|
|
if (!workingUrl.value) {
|
|
message.warning(t('coverPicker.messages.missingImage'))
|
|
return
|
|
}
|
|
|
|
if (!props.uploadHandler && !props.articleId) {
|
|
message.error(t('coverPicker.messages.missingArticle'))
|
|
return
|
|
}
|
|
|
|
if (sourceKind.value !== 'local' && !transformDirty.value && !aspectRatioLocked.value) {
|
|
emit('confirmed', {
|
|
url: workingUrl.value,
|
|
fileName: workingFileName.value || deriveCoverFileName(workingUrl.value) || 'cover.png',
|
|
assetId: selectedLibraryImageId.value,
|
|
})
|
|
emit('update:open', false)
|
|
return
|
|
}
|
|
|
|
confirmLoading.value = true
|
|
try {
|
|
if (
|
|
sourceKind.value === 'local' &&
|
|
!transformDirty.value &&
|
|
!aspectRatioLocked.value &&
|
|
localFile.value
|
|
) {
|
|
const fallbackFileName =
|
|
localFile.value.name || deriveCoverFileName(workingUrl.value) || 'cover.webp'
|
|
const uploaded = await uploadPickedFile(localFile.value, fallbackFileName)
|
|
emit('confirmed', {
|
|
url: uploaded.url,
|
|
fileName: uploaded.fileName,
|
|
assetId: uploaded.assetId,
|
|
})
|
|
emit('update:open', false)
|
|
return
|
|
}
|
|
|
|
const blob = await renderCroppedBlob(primaryRequirement.value)
|
|
const fileName = ensureImageExtension(
|
|
workingFileName.value || deriveCoverFileName(workingUrl.value) || 'cover.png',
|
|
blob.type,
|
|
)
|
|
const file = new File([blob], fileName, { type: blob.type })
|
|
const uploaded = await uploadPickedFile(file, fileName)
|
|
|
|
emit('confirmed', {
|
|
url: uploaded.url,
|
|
fileName: uploaded.fileName,
|
|
assetId: uploaded.assetId,
|
|
})
|
|
emit('update:open', false)
|
|
} catch (error) {
|
|
message.error(formatError(error))
|
|
} finally {
|
|
confirmLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function uploadPickedFile(
|
|
file: File,
|
|
fallbackFileName: string,
|
|
): Promise<{ url: string; fileName: string; assetId?: number | null }> {
|
|
if (props.uploadHandler) {
|
|
const uploaded = await props.uploadHandler(file)
|
|
return {
|
|
url: uploaded.url,
|
|
fileName: String(uploaded.fileName ?? '').trim() || fallbackFileName,
|
|
assetId: uploaded.assetId ?? null,
|
|
}
|
|
}
|
|
|
|
if (!props.articleId) {
|
|
throw new Error(t('coverPicker.messages.missingArticle'))
|
|
}
|
|
|
|
const uploaded = await articlesApi.uploadImage(props.articleId, file)
|
|
return {
|
|
url: uploaded.url,
|
|
fileName: fallbackFileName,
|
|
assetId: null,
|
|
}
|
|
}
|
|
|
|
async function renderCroppedBlob(requirement: PlatformCoverRequirement): Promise<Blob> {
|
|
const image = await loadImageElement(workingUrl.value)
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = requirement.outputWidth ?? Math.max(Math.round(stageSize.value.width * 2), 1)
|
|
canvas.height = requirement.outputHeight ?? Math.max(Math.round(stageSize.value.height * 2), 1)
|
|
|
|
const context = canvas.getContext('2d')
|
|
if (!context) {
|
|
throw new Error('article_image_upload_failed')
|
|
}
|
|
|
|
const baseScale = Math.max(
|
|
stageSize.value.width / image.naturalWidth,
|
|
stageSize.value.height / image.naturalHeight,
|
|
)
|
|
const renderedWidth = image.naturalWidth * baseScale * zoom.value
|
|
const renderedHeight = image.naturalHeight * baseScale * zoom.value
|
|
const outputScale = canvas.width / stageSize.value.width
|
|
|
|
context.fillStyle = '#ffffff'
|
|
context.fillRect(0, 0, canvas.width, canvas.height)
|
|
context.drawImage(
|
|
image,
|
|
(stageSize.value.width / 2 - renderedWidth / 2 + offsetX.value) * outputScale,
|
|
(stageSize.value.height / 2 - renderedHeight / 2 + offsetY.value) * outputScale,
|
|
renderedWidth * outputScale,
|
|
renderedHeight * outputScale,
|
|
)
|
|
|
|
const blob = await new Promise<Blob | null>((resolve) => {
|
|
canvas.toBlob(resolve, 'image/png', 0.92)
|
|
})
|
|
|
|
if (!blob) {
|
|
throw new Error('article_image_upload_failed')
|
|
}
|
|
|
|
return blob
|
|
}
|
|
|
|
function ensureImageExtension(fileName: string, mimeType: string): string {
|
|
const trimmed = fileName.trim()
|
|
const normalizedMime = mimeType.toLowerCase()
|
|
const extension = normalizedMime.includes('webp')
|
|
? 'webp'
|
|
: normalizedMime.includes('gif')
|
|
? 'gif'
|
|
: normalizedMime.includes('jpeg')
|
|
? 'jpg'
|
|
: 'png'
|
|
|
|
if (/\.(png|jpe?g|gif|webp)$/i.test(trimmed)) {
|
|
return trimmed
|
|
}
|
|
|
|
return `${trimmed || 'cover'}.${extension}`
|
|
}
|
|
|
|
function closeModal(): void {
|
|
emit('update:open', false)
|
|
}
|
|
|
|
function loadImageElement(url: string): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const image = new Image()
|
|
image.onload = () => resolve(image)
|
|
image.onerror = () => reject(new Error('article_image_upload_failed'))
|
|
image.src = url
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<a-modal
|
|
:open="open"
|
|
:width="900"
|
|
:confirm-loading="confirmLoading"
|
|
:ok-text="t('common.confirm')"
|
|
:cancel-text="t('common.cancel')"
|
|
:title="modalTitle"
|
|
@ok="handleConfirm"
|
|
@cancel="closeModal"
|
|
>
|
|
<div class="cover-picker">
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
:accept="IMAGE_UPLOAD_ACCEPT"
|
|
:disabled="imageUploadBusy"
|
|
hidden
|
|
@change="handleFileChange"
|
|
/>
|
|
|
|
<a-tabs v-model:activeKey="activeTab" class="cover-picker__tabs">
|
|
<a-tab-pane key="local" :tab="localTabLabelDisplay">
|
|
<div class="cover-picker__body">
|
|
<section class="cover-picker__workspace">
|
|
<div class="cover-picker__stage-panel">
|
|
<div
|
|
class="cover-picker__stage-frame"
|
|
:class="{ 'cover-picker__stage-frame--dragging': dragging }"
|
|
:style="stageFrameStyle"
|
|
@mousedown.prevent="handleStageMouseDown"
|
|
>
|
|
<template v-if="hasImage">
|
|
<img
|
|
class="cover-picker__stage-image"
|
|
:src="workingUrl"
|
|
:style="stageImageStyle"
|
|
alt="cover preview"
|
|
draggable="false"
|
|
/>
|
|
<div class="cover-picker__stage-outline"></div>
|
|
</template>
|
|
|
|
<button
|
|
v-else
|
|
class="cover-picker__stage-empty"
|
|
type="button"
|
|
:disabled="imageUploadBusy"
|
|
@click="openFileDialog"
|
|
>
|
|
<span class="cover-picker__stage-empty-icon">
|
|
<CloudUploadOutlined />
|
|
</span>
|
|
<strong>{{ resolvedEmptyUploadTitle }}</strong>
|
|
<small>{{ resolvedEmptyUploadHint }}</small>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="hasImage" class="cover-picker__controls">
|
|
<span>{{ t('coverPicker.zoom') }}</span>
|
|
<a-slider
|
|
class="cover-picker__slider"
|
|
:min="1"
|
|
:max="2.4"
|
|
:step="0.05"
|
|
:value="zoom"
|
|
:disabled="!hasImage"
|
|
@change="handleZoomChange"
|
|
/>
|
|
<strong>{{ Math.round(zoom * 100) }}%</strong>
|
|
<a-button size="small" style="margin-left: 16px" @click="resetTransform">
|
|
{{ t('coverPicker.actions.reset') }}
|
|
</a-button>
|
|
</div>
|
|
|
|
<div v-if="hasImage" class="cover-picker__thumbs">
|
|
<button
|
|
class="cover-picker__upload-tile"
|
|
type="button"
|
|
:disabled="imageUploadBusy"
|
|
@click="openFileDialog"
|
|
>
|
|
<span class="cover-picker__upload-tile-icon">
|
|
<PlusOutlined />
|
|
</span>
|
|
<span>{{ t('coverPicker.actions.upload') }}</span>
|
|
</button>
|
|
|
|
<div v-if="hasImage" class="cover-picker__thumb cover-picker__thumb--active">
|
|
<img :src="workingUrl" alt="cover thumbnail" />
|
|
<button type="button" class="cover-picker__thumb-close" @click="handleClear">
|
|
<CloseOutlined />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</a-tab-pane>
|
|
|
|
<a-tab-pane key="library" :tab="t('images.libraryTab')">
|
|
<div class="cover-picker__library">
|
|
<ImageLibraryPicker @select="handleLibrarySelect" />
|
|
</div>
|
|
</a-tab-pane>
|
|
</a-tabs>
|
|
</div>
|
|
</a-modal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.cover-picker {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.cover-picker__tabs :deep(.ant-tabs-nav) {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.cover-picker__body {
|
|
display: block;
|
|
}
|
|
|
|
.cover-picker__workspace,
|
|
.cover-picker__library {
|
|
border: none;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.cover-picker__workspace {
|
|
padding: 0;
|
|
}
|
|
|
|
.cover-picker__library {
|
|
padding: 0;
|
|
height: 520px;
|
|
}
|
|
|
|
.cover-picker__stage-panel {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 0;
|
|
margin-top: 0;
|
|
border-radius: 4px;
|
|
background: #f7f8fa;
|
|
min-height: 420px;
|
|
}
|
|
|
|
.cover-picker__stage-frame {
|
|
position: relative;
|
|
overflow: hidden;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
|
|
.cover-picker__stage-frame--dragging {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.cover-picker__stage-image {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
max-width: none;
|
|
object-fit: cover;
|
|
will-change: transform;
|
|
}
|
|
|
|
.cover-picker__stage-outline {
|
|
position: absolute;
|
|
inset: 0;
|
|
border: 3px solid #3b5bff;
|
|
pointer-events: none;
|
|
box-shadow:
|
|
inset 0 0 0 1px rgba(255, 255, 255, 0.65),
|
|
0 0 0 999px rgba(255, 255, 255, 0.22);
|
|
}
|
|
|
|
.cover-picker__stage-empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
height: 100%;
|
|
border: 0;
|
|
background: transparent;
|
|
color: #475467;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.cover-picker__stage-empty:disabled,
|
|
.cover-picker__upload-tile:disabled {
|
|
opacity: 0.56;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.cover-picker__ai-placeholder strong,
|
|
.cover-picker__stage-empty strong {
|
|
margin: 0;
|
|
color: #111827;
|
|
font-size: 16px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.cover-picker__ai-placeholder p,
|
|
.cover-picker__stage-empty small {
|
|
font-size: 13px;
|
|
color: #667085;
|
|
}
|
|
|
|
.cover-picker__stage-empty-icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 999px;
|
|
background: #e9ecef;
|
|
color: #fff;
|
|
font-size: 40px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.cover-picker__upload-tile-icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 999px;
|
|
background: rgba(53, 93, 255, 0.12);
|
|
color: #355dff;
|
|
font-size: 24px;
|
|
}
|
|
|
|
.cover-picker__controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
margin-top: 18px;
|
|
color: #475467;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.cover-picker__slider {
|
|
flex: 1;
|
|
margin: 0;
|
|
}
|
|
|
|
.cover-picker__thumbs {
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 14px;
|
|
margin-top: 20px;
|
|
min-height: 124px;
|
|
}
|
|
|
|
.cover-picker__upload-tile,
|
|
.cover-picker__thumb {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
width: 124px;
|
|
height: 124px;
|
|
padding: 14px;
|
|
border-radius: 20px;
|
|
border: 1px solid rgba(219, 228, 242, 0.92);
|
|
background: #fff;
|
|
color: #475467;
|
|
}
|
|
|
|
.cover-picker__upload-tile {
|
|
align-items: center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.cover-picker__thumb {
|
|
position: relative;
|
|
align-items: center;
|
|
box-shadow: inset 0 0 0 2px rgba(53, 93, 255, 0.88);
|
|
}
|
|
|
|
.cover-picker__thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 12px;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.cover-picker__thumb-close {
|
|
position: absolute;
|
|
top: -8px;
|
|
right: -8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
background-color: rgba(0, 0, 0, 0.45);
|
|
color: #ffffff !important;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
backdrop-filter: blur(4px);
|
|
transition:
|
|
background-color 0.2s,
|
|
transform 0.2s;
|
|
z-index: 10;
|
|
}
|
|
|
|
.cover-picker__thumb-close :deep(.anticon),
|
|
.cover-picker__thumb-close :deep(svg) {
|
|
color: #ffffff !important;
|
|
fill: #ffffff !important;
|
|
}
|
|
|
|
.cover-picker__thumb-close:hover {
|
|
background-color: rgba(0, 0, 0, 0.65);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
@media (max-width: 960px) {
|
|
.cover-picker__thumbs {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.cover-picker__remove {
|
|
margin-left: 0;
|
|
}
|
|
}
|
|
</style>
|