chore(frontend): introduce prettier + eslint and prune unused code
- 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>
This commit is contained in:
@@ -1,104 +1,114 @@
|
||||
<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 { 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 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";
|
||||
} 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;
|
||||
};
|
||||
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>;
|
||||
}>();
|
||||
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 }];
|
||||
}>();
|
||||
'update:open': [value: boolean]
|
||||
confirmed: [payload: { url: string; fileName: string; assetId?: number | null }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n();
|
||||
const imageUploadProgress = useImageUploadProgress();
|
||||
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 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 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 localTabLabel = computed(() =>
|
||||
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 previewHint = computed(() =>
|
||||
aspectRatioLocked.value ? t("coverPicker.previewHintLocked") : t("coverPicker.previewHintFree"),
|
||||
);
|
||||
const imageUploadBusy = computed(() => imageUploadProgress.visible);
|
||||
)
|
||||
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;
|
||||
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;
|
||||
let width = maxWidth
|
||||
let height = width / ratio
|
||||
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height * ratio;
|
||||
height = maxHeight
|
||||
width = height * ratio
|
||||
}
|
||||
|
||||
return {
|
||||
width: Math.round(width),
|
||||
height: Math.round(height),
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
const displayMetrics = computed(() => {
|
||||
if (!naturalWidth.value || !naturalHeight.value) {
|
||||
@@ -106,415 +116,430 @@ const displayMetrics = computed(() => {
|
||||
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;
|
||||
)
|
||||
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;
|
||||
hydrateFromProps()
|
||||
return
|
||||
}
|
||||
stopDragging();
|
||||
stopDragging()
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.currentUrl, props.currentFileName],
|
||||
() => {
|
||||
if (props.open && sourceKind.value !== "local") {
|
||||
hydrateFromProps();
|
||||
if (props.open && sourceKind.value !== 'local') {
|
||||
hydrateFromProps()
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.platformIds.join(","),
|
||||
() => props.platformIds.join(','),
|
||||
() => {
|
||||
if (!props.open || !hasImage.value) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
resetTransform();
|
||||
resetTransform()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [zoom.value, naturalWidth.value, naturalHeight.value, stageSize.value.width, stageSize.value.height],
|
||||
() => [
|
||||
zoom.value,
|
||||
naturalWidth.value,
|
||||
naturalHeight.value,
|
||||
stageSize.value.width,
|
||||
stageSize.value.height,
|
||||
],
|
||||
() => {
|
||||
clampOffsets();
|
||||
clampOffsets()
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopDragging();
|
||||
revokeLocalObjectUrl();
|
||||
});
|
||||
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);
|
||||
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;
|
||||
workingUrl.value = nextUrl
|
||||
workingFileName.value = nextFileName
|
||||
sourceKind.value = nextUrl ? 'remote' : null
|
||||
localFile.value = null
|
||||
selectedLibraryImageId.value = props.currentAssetId ?? null
|
||||
|
||||
if (!nextUrl) {
|
||||
resetStage();
|
||||
return;
|
||||
resetStage()
|
||||
return
|
||||
}
|
||||
|
||||
void loadImageMeta(nextUrl);
|
||||
void loadImageMeta(nextUrl)
|
||||
}
|
||||
|
||||
function resetStage(): void {
|
||||
naturalWidth.value = 0;
|
||||
naturalHeight.value = 0;
|
||||
resetTransform();
|
||||
naturalWidth.value = 0
|
||||
naturalHeight.value = 0
|
||||
resetTransform()
|
||||
}
|
||||
|
||||
function resetTransform(): void {
|
||||
zoom.value = 1;
|
||||
offsetX.value = 0;
|
||||
offsetY.value = 0;
|
||||
transformDirty.value = false;
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
async function loadImageMeta(url: string): Promise<void> {
|
||||
const token = imageLoadToken.value + 1;
|
||||
imageLoadToken.value = token;
|
||||
const token = imageLoadToken.value + 1
|
||||
imageLoadToken.value = token
|
||||
|
||||
try {
|
||||
const image = await loadImageElement(url);
|
||||
const image = await loadImageElement(url)
|
||||
if (token !== imageLoadToken.value) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
naturalWidth.value = image.naturalWidth;
|
||||
naturalHeight.value = image.naturalHeight;
|
||||
resetTransform();
|
||||
naturalWidth.value = image.naturalWidth
|
||||
naturalHeight.value = image.naturalHeight
|
||||
resetTransform()
|
||||
} catch {
|
||||
if (token !== imageLoadToken.value) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
resetStage();
|
||||
message.warning(t("coverPicker.messages.imageLoadFailed"));
|
||||
resetStage()
|
||||
message.warning(t('coverPicker.messages.imageLoadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function openFileDialog(): void {
|
||||
if (imageUploadBusy.value) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
fileInput.value?.click();
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event): void {
|
||||
if (imageUploadBusy.value) {
|
||||
(event.target as HTMLInputElement).value = "";
|
||||
return;
|
||||
;(event.target as HTMLInputElement).value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
validateImageUploadFile(file);
|
||||
validateImageUploadFile(file)
|
||||
} catch (error) {
|
||||
message.warning(formatError(error));
|
||||
return;
|
||||
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);
|
||||
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;
|
||||
return
|
||||
}
|
||||
URL.revokeObjectURL(localObjectUrl.value);
|
||||
localObjectUrl.value = "";
|
||||
URL.revokeObjectURL(localObjectUrl.value)
|
||||
localObjectUrl.value = ''
|
||||
}
|
||||
|
||||
function handleStageMouseDown(event: MouseEvent): void {
|
||||
if (!hasImage.value || confirmLoading.value) {
|
||||
return;
|
||||
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);
|
||||
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;
|
||||
return
|
||||
}
|
||||
|
||||
offsetX.value = dragOriginX.value + event.clientX - dragStartX.value;
|
||||
offsetY.value = dragOriginY.value + event.clientY - dragStartY.value;
|
||||
clampOffsets();
|
||||
markTransformDirty();
|
||||
offsetX.value = dragOriginX.value + event.clientX - dragStartX.value
|
||||
offsetY.value = dragOriginY.value + event.clientY - dragStartY.value
|
||||
clampOffsets()
|
||||
markTransformDirty()
|
||||
}
|
||||
|
||||
function handleWindowMouseUp(): void {
|
||||
stopDragging();
|
||||
stopDragging()
|
||||
}
|
||||
|
||||
function stopDragging(): void {
|
||||
if (!dragging.value) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
dragging.value = false;
|
||||
window.removeEventListener("mousemove", handleWindowMouseMove);
|
||||
window.removeEventListener("mouseup", handleWindowMouseUp);
|
||||
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();
|
||||
const nextValue = Array.isArray(value) ? value[0] : value
|
||||
zoom.value = clamp(nextValue, 1, 2.4)
|
||||
clampOffsets()
|
||||
markTransformDirty()
|
||||
}
|
||||
|
||||
function handleClear(): void {
|
||||
if (confirmLoading.value) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
workingUrl.value = "";
|
||||
workingFileName.value = "";
|
||||
sourceKind.value = null;
|
||||
localFile.value = null;
|
||||
selectedLibraryImageId.value = null;
|
||||
transformDirty.value = false;
|
||||
resetStage();
|
||||
revokeLocalObjectUrl();
|
||||
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);
|
||||
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;
|
||||
message.warning(t('coverPicker.messages.missingImage'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.uploadHandler && !props.articleId) {
|
||||
message.error(t("coverPicker.messages.missingArticle"));
|
||||
return;
|
||||
message.error(t('coverPicker.messages.missingArticle'))
|
||||
return
|
||||
}
|
||||
|
||||
if (sourceKind.value !== "local" && !transformDirty.value && !aspectRatioLocked.value) {
|
||||
emit("confirmed", {
|
||||
if (sourceKind.value !== 'local' && !transformDirty.value && !aspectRatioLocked.value) {
|
||||
emit('confirmed', {
|
||||
url: workingUrl.value,
|
||||
fileName: workingFileName.value || deriveCoverFileName(workingUrl.value) || "cover.png",
|
||||
fileName: workingFileName.value || deriveCoverFileName(workingUrl.value) || 'cover.png',
|
||||
assetId: selectedLibraryImageId.value,
|
||||
});
|
||||
emit("update:open", false);
|
||||
return;
|
||||
})
|
||||
emit('update:open', false)
|
||||
return
|
||||
}
|
||||
|
||||
confirmLoading.value = true;
|
||||
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", {
|
||||
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;
|
||||
})
|
||||
emit('update:open', false)
|
||||
return
|
||||
}
|
||||
|
||||
const blob = await renderCroppedBlob(primaryRequirement.value);
|
||||
const blob = await renderCroppedBlob(primaryRequirement.value)
|
||||
const fileName = ensureImageExtension(
|
||||
workingFileName.value || deriveCoverFileName(workingUrl.value) || "cover.png",
|
||||
workingFileName.value || deriveCoverFileName(workingUrl.value) || 'cover.png',
|
||||
blob.type,
|
||||
);
|
||||
const file = new File([blob], fileName, { type: blob.type });
|
||||
const uploaded = await uploadPickedFile(file, fileName);
|
||||
)
|
||||
const file = new File([blob], fileName, { type: blob.type })
|
||||
const uploaded = await uploadPickedFile(file, fileName)
|
||||
|
||||
emit("confirmed", {
|
||||
emit('confirmed', {
|
||||
url: uploaded.url,
|
||||
fileName: uploaded.fileName,
|
||||
assetId: uploaded.assetId,
|
||||
});
|
||||
emit("update:open", false);
|
||||
})
|
||||
emit('update:open', false)
|
||||
} catch (error) {
|
||||
message.error(formatError(error));
|
||||
message.error(formatError(error))
|
||||
} finally {
|
||||
confirmLoading.value = false;
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPickedFile(file: File, fallbackFileName: string): Promise<{ url: string; fileName: string; assetId?: number | null }> {
|
||||
async function uploadPickedFile(
|
||||
file: File,
|
||||
fallbackFileName: string,
|
||||
): Promise<{ url: string; fileName: string; assetId?: number | null }> {
|
||||
if (props.uploadHandler) {
|
||||
const uploaded = await props.uploadHandler(file);
|
||||
const uploaded = await props.uploadHandler(file)
|
||||
return {
|
||||
url: uploaded.url,
|
||||
fileName: String(uploaded.fileName ?? "").trim() || fallbackFileName,
|
||||
fileName: String(uploaded.fileName ?? '').trim() || fallbackFileName,
|
||||
assetId: uploaded.assetId ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.articleId) {
|
||||
throw new Error(t("coverPicker.messages.missingArticle"));
|
||||
throw new Error(t('coverPicker.messages.missingArticle'))
|
||||
}
|
||||
|
||||
const uploaded = await articlesApi.uploadImage(props.articleId, file);
|
||||
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 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");
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) {
|
||||
throw new Error("article_image_upload_failed");
|
||||
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;
|
||||
)
|
||||
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.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);
|
||||
});
|
||||
canvas.toBlob(resolve, 'image/png', 0.92)
|
||||
})
|
||||
|
||||
if (!blob) {
|
||||
throw new Error("article_image_upload_failed");
|
||||
throw new Error('article_image_upload_failed')
|
||||
}
|
||||
|
||||
return blob;
|
||||
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";
|
||||
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
|
||||
}
|
||||
|
||||
return `${trimmed || "cover"}.${extension}`;
|
||||
return `${trimmed || 'cover'}.${extension}`
|
||||
}
|
||||
|
||||
function closeModal(): void {
|
||||
emit("update:open", false);
|
||||
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;
|
||||
});
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error('article_image_upload_failed'))
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -540,7 +565,7 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
|
||||
/>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" class="cover-picker__tabs">
|
||||
<a-tab-pane key="local" :tab="localTabLabel">
|
||||
<a-tab-pane key="local" :tab="localTabLabelDisplay">
|
||||
<div class="cover-picker__body">
|
||||
<section class="cover-picker__workspace">
|
||||
<div class="cover-picker__stage-panel">
|
||||
@@ -578,7 +603,7 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
|
||||
</div>
|
||||
|
||||
<div v-if="hasImage" class="cover-picker__controls">
|
||||
<span>{{ t("coverPicker.zoom") }}</span>
|
||||
<span>{{ t('coverPicker.zoom') }}</span>
|
||||
<a-slider
|
||||
class="cover-picker__slider"
|
||||
:min="1"
|
||||
@@ -589,8 +614,8 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
|
||||
@change="handleZoomChange"
|
||||
/>
|
||||
<strong>{{ Math.round(zoom * 100) }}%</strong>
|
||||
<a-button size="small" @click="resetTransform" style="margin-left: 16px;">
|
||||
{{ t("coverPicker.actions.reset") }}
|
||||
<a-button size="small" style="margin-left: 16px" @click="resetTransform">
|
||||
{{ t('coverPicker.actions.reset') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
@@ -604,13 +629,10 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
|
||||
<span class="cover-picker__upload-tile-icon">
|
||||
<PlusOutlined />
|
||||
</span>
|
||||
<span>{{ t("coverPicker.actions.upload") }}</span>
|
||||
<span>{{ t('coverPicker.actions.upload') }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="hasImage"
|
||||
class="cover-picker__thumb cover-picker__thumb--active"
|
||||
>
|
||||
<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 />
|
||||
@@ -837,7 +859,9 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
transform 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user