1972 lines
58 KiB
Vue
1972 lines
58 KiB
Vue
<script setup>
|
|
import {
|
|
DISPLAY_COUNT_OPTIONS,
|
|
POST_LIST_COUNT_OPTIONS
|
|
} from '~/lib/display-count-settings.js'
|
|
import {
|
|
UPLOAD_FILE_COUNT_OPTIONS,
|
|
UPLOAD_TOTAL_SIZE_OPTIONS,
|
|
formatUploadSizeLimit
|
|
} from '~/lib/upload-size-limit.js'
|
|
import {
|
|
POST_SETTINGS_FIELDS,
|
|
assignPostSettingsSection,
|
|
createDefaultPostSettingsSnapshot,
|
|
hasPostSettingsSectionChanges,
|
|
normalizePostSettingsSection
|
|
} from '~/lib/admin-post-settings-section.js'
|
|
import {
|
|
SOCIAL_SETTINGS_FIELDS,
|
|
assignSocialSettingsSection,
|
|
createDefaultSocialSettingsSnapshot,
|
|
hasSocialSettingsSectionChanges,
|
|
normalizeSocialSettingsSection
|
|
} from '~/lib/admin-social-settings-section.js'
|
|
import {
|
|
SPAM_SETTINGS_FIELDS,
|
|
assignSpamSettingsSection,
|
|
createDefaultSpamSettingsSnapshot,
|
|
formatSpamBlockedUsernamesText,
|
|
hasSpamSettingsSectionChanges,
|
|
normalizeSpamSettingsSection,
|
|
parseSpamBlockedUsernamesText
|
|
} from '~/lib/admin-spam-settings-section.js'
|
|
import {
|
|
BRAND_SETTINGS_FIELDS,
|
|
assignBrandSettingsSection,
|
|
createDefaultBrandSettingsSnapshot,
|
|
hasBrandSettingsSectionChanges,
|
|
normalizeBrandSettingsSection
|
|
} from '~/lib/admin-brand-settings-section.js'
|
|
import {
|
|
ANNOUNCEMENT_SETTINGS_FIELDS,
|
|
assignAnnouncementSettingsSection,
|
|
createDefaultAnnouncementSettingsSnapshot,
|
|
hasAnnouncementSettingsSectionChanges,
|
|
normalizeAnnouncementSettingsSection
|
|
} from '~/lib/admin-announcement-settings-section.js'
|
|
|
|
definePageMeta({
|
|
layout: 'admin'
|
|
})
|
|
|
|
const router = useRouter()
|
|
|
|
const savingTitleDesc = ref(false)
|
|
const savingMisc = ref(false)
|
|
const savingSocial = ref(false)
|
|
const savingPost = ref(false)
|
|
const savingHomeCover = ref(false)
|
|
const savingBrand = ref(false)
|
|
const savingAnnouncement = ref(false)
|
|
const savingSpam = ref(false)
|
|
const savingSiteCode = ref(false)
|
|
const savingAds = ref(false)
|
|
const uploadingLogo = ref(false)
|
|
const uploadingHomeCover = ref(false)
|
|
const uploadingHomeCoverDark = ref(false)
|
|
const requestingPostExport = ref(false)
|
|
const deletingPostExportJobIds = ref([])
|
|
const downloadingPostExportJobIds = ref([])
|
|
const selectedPostExportFileIds = ref({})
|
|
const retryingPostExportJobIds = ref([])
|
|
const deletingPostImportJobIds = ref([])
|
|
const importingPosts = ref(false)
|
|
const postImportFile = ref(null)
|
|
const postImportFileName = ref('')
|
|
const selectedPostImportJobId = ref('')
|
|
const isPostImportJobModalOpen = ref(false)
|
|
const postExportDateRangeMode = ref('all')
|
|
const postExportYear = ref(new Date().getFullYear())
|
|
const postExportMonth = ref(new Date().getMonth() + 1)
|
|
const postExportDateFrom = ref('')
|
|
const postExportDateTo = ref('')
|
|
const postExportChunkSize = ref(500)
|
|
const postExportMaxFileSizeMb = ref(500)
|
|
const postImportExportPanel = ref('')
|
|
const errorMessage = ref('')
|
|
const { toast, showToast } = useAdminToast()
|
|
const {
|
|
mainScrollRef,
|
|
settingsNavGroups,
|
|
filteredSettingsNavGroups,
|
|
navSearchInput,
|
|
onNavSearchInput,
|
|
onNavSearchCompositionEnd,
|
|
activeSectionId,
|
|
onMainScroll,
|
|
refreshActiveSection,
|
|
scrollToSection,
|
|
cleanupSettingsNavigation
|
|
} = useAdminSettingsNavigation()
|
|
|
|
const uploadFileCountSelectOptions = UPLOAD_FILE_COUNT_OPTIONS.map((option) => ({
|
|
value: option,
|
|
label: `${option}개`
|
|
}))
|
|
const uploadTotalSizeSelectOptions = UPLOAD_TOTAL_SIZE_OPTIONS.map((option) => ({
|
|
value: option,
|
|
label: formatUploadSizeLimit(option)
|
|
}))
|
|
const postListCountSelectOptions = POST_LIST_COUNT_OPTIONS.map((option) => ({
|
|
value: option,
|
|
label: `${option}개`
|
|
}))
|
|
const displayCountSelectOptions = DISPLAY_COUNT_OPTIONS.map((option) => ({
|
|
value: option,
|
|
label: `${option}개`
|
|
}))
|
|
const postExportDateRangeModeOptions = [
|
|
{ value: 'all', label: '전체' },
|
|
{ value: 'year', label: '특정년' },
|
|
{ value: 'month', label: '특정월' },
|
|
{ value: 'custom', label: '직접 지정' }
|
|
]
|
|
const titleDescFields = ['title', 'description']
|
|
const miscFields = ['siteUrl', 'logoText', 'logoUrl', 'faviconUrl', 'copyrightText']
|
|
const homeCoverFields = ['homeCoverImageUrl', 'homeCoverDarkImageUrl', 'homeCoverTitle', 'homeCoverText']
|
|
const siteCodeFields = ['adsTxt', 'customHeadCode', 'customFooterCode']
|
|
const adsFields = [
|
|
'adHomeFeedCode',
|
|
'adHomeInfeedCode',
|
|
'adSidebarCode',
|
|
'adPostSidebarCode',
|
|
'adPostTopCode',
|
|
'adPostInArticleCode',
|
|
'adPostBottomCode'
|
|
]
|
|
|
|
/** 블로그 제목·설명 카드 편집 모드 여부 */
|
|
const editTitleDesc = ref(false)
|
|
/** 사이트 정보 카드 편집 모드 여부 */
|
|
const editMisc = ref(false)
|
|
/** SNS 정보 카드 편집 모드 여부 */
|
|
const editSocial = ref(false)
|
|
/** POST 설정 카드 편집 모드 여부 */
|
|
const editPost = ref(false)
|
|
/** 메인 화면 커버 카드 편집 모드 여부 */
|
|
const editHomeCover = ref(false)
|
|
/** 브랜드 컬러 카드 편집 모드 여부 */
|
|
const editBrand = ref(false)
|
|
/** 어나운스 바 맞춤 설정 패널 열림 여부 */
|
|
const customizeAnnouncement = ref(false)
|
|
/** 스팸 필터 카드 편집 모드 여부 */
|
|
const editSpam = ref(false)
|
|
/** 사이트 코드 카드 편집 모드 여부 */
|
|
const editSiteCode = ref(false)
|
|
/** 광고 슬롯 카드 편집 모드 여부 */
|
|
const editAds = ref(false)
|
|
/** 편집 시작 시점의 제목·설명(취소 시 복원용) */
|
|
const titleDescSnapshot = reactive({
|
|
title: '',
|
|
description: ''
|
|
})
|
|
/** 편집 시작 시점의 사이트 정보(취소 시 복원용) */
|
|
const miscSnapshot = reactive({
|
|
siteUrl: '',
|
|
logoText: '',
|
|
logoUrl: '',
|
|
faviconUrl: '',
|
|
copyrightText: ''
|
|
})
|
|
/** 편집 시작 시점의 SNS 정보(취소 시 복원용) */
|
|
const socialSnapshot = reactive(createDefaultSocialSettingsSnapshot())
|
|
/** 편집 시작 시점의 POST 설정(취소 시 복원용) */
|
|
const postSnapshot = reactive(createDefaultPostSettingsSnapshot())
|
|
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
|
|
const homeCoverSnapshot = reactive({
|
|
homeCoverImageUrl: '',
|
|
homeCoverDarkImageUrl: '',
|
|
homeCoverTitle: '',
|
|
homeCoverText: ''
|
|
})
|
|
/** 편집 시작 시점의 브랜드 설정(취소 시 복원용) */
|
|
const brandSnapshot = reactive(createDefaultBrandSettingsSnapshot())
|
|
/** 맞춤 설정 시작 시점의 어나운스 바(취소 시 복원용) */
|
|
const announcementSnapshot = reactive(createDefaultAnnouncementSettingsSnapshot())
|
|
/** 편집 시작 시점의 스팸 필터(취소 시 복원용) */
|
|
const spamSnapshot = reactive(createDefaultSpamSettingsSnapshot())
|
|
/** 편집 시작 시점의 사이트 코드(취소 시 복원용) */
|
|
const siteCodeSnapshot = reactive({
|
|
adsTxt: '',
|
|
customHeadCode: '',
|
|
customFooterCode: ''
|
|
})
|
|
/** 편집 시작 시점의 광고 슬롯(취소 시 복원용) */
|
|
const adsSnapshot = reactive({
|
|
adHomeFeedCode: '',
|
|
adHomeInfeedCode: '',
|
|
adSidebarCode: '',
|
|
adPostSidebarCode: '',
|
|
adPostTopCode: '',
|
|
adPostInArticleCode: '',
|
|
adPostBottomCode: ''
|
|
})
|
|
let postExportRefreshTimer = null
|
|
let postImportRefreshTimer = null
|
|
|
|
const { data: settings } = await useFetch('/admin/api/settings')
|
|
const {
|
|
data: postExportJobs,
|
|
refresh: refreshPostExportJobs
|
|
} = await useFetch('/admin/api/posts/export-jobs', {
|
|
default: () => []
|
|
})
|
|
const {
|
|
data: postImportJobs,
|
|
refresh: refreshPostImportJobs
|
|
} = await useFetch('/admin/api/posts/import-jobs', {
|
|
default: () => []
|
|
})
|
|
const {
|
|
data: backupSummary,
|
|
pending: backupSummaryPending,
|
|
refresh: refreshBackupSummary
|
|
} = await useFetch('/admin/api/settings/backup-summary', {
|
|
default: () => null
|
|
})
|
|
const initialPostSettings = normalizePostSettingsSection(settings.value)
|
|
const initialSocialSettings = normalizeSocialSettingsSection(settings.value)
|
|
const initialSpamSettings = normalizeSpamSettingsSection(settings.value)
|
|
const initialBrandSettings = normalizeBrandSettingsSection(settings.value)
|
|
const initialAnnouncementSettings = normalizeAnnouncementSettingsSection(settings.value)
|
|
|
|
const form = reactive({
|
|
title: settings.value?.title || 'sori.studio',
|
|
description: settings.value?.description || '',
|
|
siteUrl: settings.value?.siteUrl || 'https://sori.studio',
|
|
logoText: settings.value?.logoText || '井',
|
|
logoUrl: settings.value?.logoUrl || '',
|
|
faviconUrl: settings.value?.faviconUrl || '',
|
|
copyrightText: settings.value?.copyrightText || '©2026 sori.studio',
|
|
...initialSocialSettings,
|
|
...initialPostSettings,
|
|
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
|
|
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
|
|
homeCoverTitle: settings.value?.homeCoverTitle || '',
|
|
homeCoverText: settings.value?.homeCoverText || '',
|
|
...initialBrandSettings,
|
|
...initialAnnouncementSettings,
|
|
...initialSpamSettings,
|
|
adsTxt: settings.value?.adsTxt || '',
|
|
customHeadCode: settings.value?.customHeadCode || '',
|
|
customFooterCode: settings.value?.customFooterCode || '',
|
|
adHomeFeedCode: settings.value?.adHomeFeedCode || '',
|
|
adHomeInfeedCode: settings.value?.adHomeInfeedCode || '',
|
|
adSidebarCode: settings.value?.adSidebarCode || '',
|
|
adPostSidebarCode: settings.value?.adPostSidebarCode || '',
|
|
adPostTopCode: settings.value?.adPostTopCode || '',
|
|
adPostInArticleCode: settings.value?.adPostInArticleCode || '',
|
|
adPostBottomCode: settings.value?.adPostBottomCode || ''
|
|
})
|
|
|
|
/**
|
|
* 블로그 제목·설명 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasTitleDescChanges = computed(() => editTitleDesc.value && (
|
|
form.title !== titleDescSnapshot.title
|
|
|| form.description !== titleDescSnapshot.description
|
|
))
|
|
|
|
/**
|
|
* 사이트 정보 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasMiscChanges = computed(() => editMisc.value && (
|
|
form.siteUrl !== miscSnapshot.siteUrl
|
|
|| form.logoText !== miscSnapshot.logoText
|
|
|| form.logoUrl !== miscSnapshot.logoUrl
|
|
|| form.faviconUrl !== miscSnapshot.faviconUrl
|
|
|| form.copyrightText !== miscSnapshot.copyrightText
|
|
))
|
|
|
|
/**
|
|
* SNS 정보 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasSocialChanges = computed(() => editSocial.value
|
|
&& hasSocialSettingsSectionChanges(form, socialSnapshot))
|
|
|
|
/**
|
|
* POST 설정 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasPostChanges = computed(() => editPost.value
|
|
&& hasPostSettingsSectionChanges(form, postSnapshot))
|
|
|
|
/**
|
|
* 메인 화면 커버 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasHomeCoverChanges = computed(() => editHomeCover.value && (
|
|
form.homeCoverImageUrl !== homeCoverSnapshot.homeCoverImageUrl
|
|
|| form.homeCoverDarkImageUrl !== homeCoverSnapshot.homeCoverDarkImageUrl
|
|
|| form.homeCoverTitle !== homeCoverSnapshot.homeCoverTitle
|
|
|| form.homeCoverText !== homeCoverSnapshot.homeCoverText
|
|
))
|
|
|
|
/**
|
|
* 브랜드 설정 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasBrandChanges = computed(() => editBrand.value
|
|
&& hasBrandSettingsSectionChanges(form, brandSnapshot))
|
|
|
|
/**
|
|
* 어나운스 바 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasAnnouncementChanges = computed(() => customizeAnnouncement.value && (
|
|
hasAnnouncementSettingsSectionChanges(form, announcementSnapshot)
|
|
))
|
|
|
|
/**
|
|
* 스팸 필터 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasSpamChanges = computed(() => editSpam.value
|
|
&& hasSpamSettingsSectionChanges(form, spamSnapshot))
|
|
|
|
/**
|
|
* 사이트 코드 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasSiteCodeChanges = computed(() => editSiteCode.value && (
|
|
form.adsTxt !== siteCodeSnapshot.adsTxt
|
|
|| form.customHeadCode !== siteCodeSnapshot.customHeadCode
|
|
|| form.customFooterCode !== siteCodeSnapshot.customFooterCode
|
|
))
|
|
|
|
/**
|
|
* 광고 슬롯 변경 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const hasAdsChanges = computed(() => editAds.value && (
|
|
form.adHomeFeedCode !== adsSnapshot.adHomeFeedCode
|
|
|| form.adHomeInfeedCode !== adsSnapshot.adHomeInfeedCode
|
|
|| form.adSidebarCode !== adsSnapshot.adSidebarCode
|
|
|| form.adPostSidebarCode !== adsSnapshot.adPostSidebarCode
|
|
|| form.adPostTopCode !== adsSnapshot.adPostTopCode
|
|
|| form.adPostInArticleCode !== adsSnapshot.adPostInArticleCode
|
|
|| form.adPostBottomCode !== adsSnapshot.adPostBottomCode
|
|
))
|
|
|
|
/**
|
|
* 최신 게시물 export 작업 목록
|
|
* @returns {Array} export 작업 목록
|
|
*/
|
|
const normalizedPostExportJobs = computed(() => Array.isArray(postExportJobs.value) ? postExportJobs.value : [])
|
|
|
|
/**
|
|
* 최신 게시물 Import 작업 목록
|
|
* @returns {Array} Import 작업 목록
|
|
*/
|
|
const normalizedPostImportJobs = computed(() => Array.isArray(postImportJobs.value) ? postImportJobs.value : [])
|
|
|
|
/**
|
|
* 진행 중인 게시물 export 작업이 있는지 확인한다.
|
|
* @returns {boolean} 진행 중 작업 여부
|
|
*/
|
|
const hasActivePostExportJobs = computed(() => normalizedPostExportJobs.value.some((job) => (
|
|
job.status === 'queued' || job.status === 'processing'
|
|
)))
|
|
|
|
/**
|
|
* 진행 중인 게시물 Import 작업이 있는지 확인한다.
|
|
* @returns {boolean} 진행 중 작업 여부
|
|
*/
|
|
const hasActivePostImportJobs = computed(() => normalizedPostImportJobs.value.some((job) => (
|
|
job.status === 'queued' || job.status === 'processing'
|
|
)))
|
|
|
|
/**
|
|
* 선택된 Import 작업
|
|
* @returns {Object|null} Import 작업
|
|
*/
|
|
const selectedPostImportJob = computed(() => normalizedPostImportJobs.value.find((job) => job.id === selectedPostImportJobId.value) || normalizedPostImportJobs.value[0] || null)
|
|
|
|
/**
|
|
* Export 연도 선택지
|
|
* @returns {Array<number>} 연도 목록
|
|
*/
|
|
const postExportYearOptions = computed(() => {
|
|
const currentYear = new Date().getFullYear()
|
|
return Array.from({ length: 10 }, (_, index) => currentYear - index)
|
|
})
|
|
|
|
const postExportYearSelectOptions = computed(() => postExportYearOptions.value.map((year) => ({
|
|
value: year,
|
|
label: String(year)
|
|
})))
|
|
|
|
/**
|
|
* Export 월 선택지
|
|
* @type {ReadonlyArray<number>}
|
|
*/
|
|
const postExportMonthOptions = Array.from({ length: 12 }, (_, index) => index + 1)
|
|
|
|
const postExportMonthSelectOptions = postExportMonthOptions.map((month) => ({
|
|
value: month,
|
|
label: `${month}월`
|
|
}))
|
|
|
|
/**
|
|
* 게시물 export 요청 버튼 활성 가능 여부
|
|
* @returns {boolean} 활성 가능 여부
|
|
*/
|
|
const canRequestPostExport = computed(() => {
|
|
if (requestingPostExport.value || hasActivePostExportJobs.value) {
|
|
return false
|
|
}
|
|
|
|
const hasValidSplitOptions = postExportChunkSize.value >= 1
|
|
&& postExportChunkSize.value <= 500
|
|
&& postExportMaxFileSizeMb.value >= 10
|
|
&& postExportMaxFileSizeMb.value <= 2048
|
|
|
|
if (!hasValidSplitOptions) {
|
|
return false
|
|
}
|
|
|
|
if (postExportDateRangeMode.value === 'custom') {
|
|
return Boolean(
|
|
postExportDateFrom.value
|
|
&& postExportDateTo.value
|
|
&& postExportDateFrom.value <= postExportDateTo.value
|
|
)
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
/**
|
|
* 게시물 export 요청 버튼 안내 문구
|
|
* @returns {string} 안내 문구
|
|
*/
|
|
const postExportRequestTitle = computed(() => {
|
|
if (hasActivePostExportJobs.value) {
|
|
return '진행 중인 내보내기 작업이 끝난 뒤 새 요청을 만들 수 있습니다.'
|
|
}
|
|
|
|
if (postExportDateRangeMode.value === 'custom' && !canRequestPostExport.value) {
|
|
return '올바른 시작일과 종료일을 선택해 주세요.'
|
|
}
|
|
|
|
if (!canRequestPostExport.value) {
|
|
return 'ZIP당 최대 게시물 수와 목표 용량을 확인해 주세요.'
|
|
}
|
|
|
|
return '게시물 내보내기 작업을 요청합니다.'
|
|
})
|
|
|
|
/**
|
|
* Import/Export 상세 패널을 전환한다.
|
|
* @param {'import'|'export'} panelName - 열 패널 이름
|
|
* @returns {void}
|
|
*/
|
|
const togglePostImportExportPanel = (panelName) => {
|
|
postImportExportPanel.value = postImportExportPanel.value === panelName ? '' : panelName
|
|
}
|
|
|
|
/**
|
|
* Export 요청 범위 입력을 만든다.
|
|
* @returns {Object} Export 범위 입력
|
|
*/
|
|
const createPostExportRequestBody = () => {
|
|
const base = {
|
|
chunkSize: Number(postExportChunkSize.value),
|
|
maxFileSizeBytes: Number(postExportMaxFileSizeMb.value) * 1024 * 1024,
|
|
retentionDays: 100,
|
|
dateRangeMode: postExportDateRangeMode.value
|
|
}
|
|
|
|
if (postExportDateRangeMode.value === 'year') {
|
|
return {
|
|
...base,
|
|
year: Number(postExportYear.value)
|
|
}
|
|
}
|
|
|
|
if (postExportDateRangeMode.value === 'month') {
|
|
return {
|
|
...base,
|
|
year: Number(postExportYear.value),
|
|
month: Number(postExportMonth.value)
|
|
}
|
|
}
|
|
|
|
if (postExportDateRangeMode.value === 'custom') {
|
|
return {
|
|
...base,
|
|
dateFrom: postExportDateFrom.value,
|
|
dateTo: postExportDateTo.value
|
|
}
|
|
}
|
|
|
|
return base
|
|
}
|
|
|
|
/**
|
|
* export 상태 라벨 조회
|
|
* @param {string} status - export 상태
|
|
* @returns {string} 상태 라벨
|
|
*/
|
|
const getPostExportStatusLabel = (status) => ({
|
|
queued: '대기 중',
|
|
processing: '생성 중',
|
|
ready: '준비 완료',
|
|
failed: '실패',
|
|
expired: '만료'
|
|
}[status] || '알 수 없음')
|
|
|
|
/**
|
|
* export 상태 배지 클래스 조회
|
|
* @param {string} status - export 상태
|
|
* @returns {string} Tailwind 클래스
|
|
*/
|
|
const getPostExportStatusClass = (status) => ({
|
|
queued: 'bg-[#eef4ff] text-[#2f5fbb] ring-[#c9dafd]',
|
|
processing: 'bg-[#fff7e8] text-[#9a6200] ring-[#f3d39b]',
|
|
ready: 'bg-[#eaf8f0] text-[#147a45] ring-[#b9e7cd]',
|
|
failed: 'bg-[#fff0f0] text-[#c53232] ring-[#f3c2c2]',
|
|
expired: 'bg-[#f1f3f5] text-[#657080] ring-[#dce0e5]'
|
|
}[status] || 'bg-[#f1f3f5] text-[#657080] ring-[#dce0e5]')
|
|
|
|
/**
|
|
* export 작업 진행률을 계산한다.
|
|
* @param {Object} job - export 작업
|
|
* @returns {number} 진행률
|
|
*/
|
|
const getPostExportProgressPercent = (job) => {
|
|
const total = Number(job?.postCount || 0)
|
|
const processed = Number(job?.processedCount || 0)
|
|
|
|
if (!total) {
|
|
return job?.status === 'ready' ? 100 : 0
|
|
}
|
|
|
|
return Math.min(Math.max(Math.round((processed / total) * 100), 0), 100)
|
|
}
|
|
|
|
/**
|
|
* export 작업 진행 숫자 라벨을 만든다.
|
|
* @param {Object} job - export 작업
|
|
* @returns {string} 진행 숫자 라벨
|
|
*/
|
|
const getPostExportProgressLabel = (job) => {
|
|
const total = Number(job?.postCount || 0)
|
|
const processed = Math.min(Number(job?.processedCount || 0), total)
|
|
|
|
if (!total) {
|
|
return '0 / 0'
|
|
}
|
|
|
|
return `${processed.toLocaleString()} / ${total.toLocaleString()}`
|
|
}
|
|
|
|
/**
|
|
* export 작업 진행 보조 설명을 만든다.
|
|
* @param {Object} job - export 작업
|
|
* @returns {string} 진행 보조 설명
|
|
*/
|
|
const getPostExportProgressDescription = (job) => {
|
|
if (job.progressMessage) {
|
|
return job.progressMessage
|
|
}
|
|
|
|
if (job.status === 'queued') {
|
|
return '작업 대기열에 등록되었습니다. 곧 파일 생성이 시작됩니다.'
|
|
}
|
|
|
|
if (job.status === 'processing') {
|
|
return job.currentPartIndex
|
|
? `${job.currentPartIndex}번째 분할 파일을 생성하는 중입니다.`
|
|
: '분할 파일을 생성하는 중입니다.'
|
|
}
|
|
|
|
if (job.status === 'ready') {
|
|
return '생성이 완료되었습니다.'
|
|
}
|
|
|
|
return job.message || ''
|
|
}
|
|
|
|
/**
|
|
* 가입 금지 닉네임 textarea 바인딩
|
|
*/
|
|
const signupBlockedUsernamesText = computed({
|
|
get: () => formatSpamBlockedUsernamesText(form),
|
|
set: (value) => {
|
|
assignSpamSettingsSection(form, parseSpamBlockedUsernamesText(value))
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 설정 화면을 닫고 이전 관리 화면으로 돌아간다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const closeSettings = async () => {
|
|
if (import.meta.client && window.history.length > 1) {
|
|
await router.back()
|
|
return
|
|
}
|
|
await navigateTo('/admin')
|
|
}
|
|
|
|
/**
|
|
* 게시물 export 작업을 요청한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const requestPostExport = async () => {
|
|
if (!canRequestPostExport.value) {
|
|
return
|
|
}
|
|
|
|
requestingPostExport.value = true
|
|
errorMessage.value = ''
|
|
showToast('info', '게시물 내보내기 작업을 등록하는 중입니다.')
|
|
|
|
try {
|
|
await $fetch('/admin/api/posts/export-jobs', {
|
|
method: 'POST',
|
|
body: createPostExportRequestBody()
|
|
})
|
|
await refreshPostExportJobs()
|
|
showToast('success', '게시물 내보내기 작업이 등록되었습니다.')
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '게시물 내보내기 작업을 등록하지 못했습니다.'
|
|
showToast('error', errorMessage.value)
|
|
} finally {
|
|
requestingPostExport.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시물 Import 대상 파일을 선택한다.
|
|
* @param {File|null|undefined} file - 선택 파일
|
|
* @returns {boolean} 선택 성공 여부
|
|
*/
|
|
const selectPostImportFile = (file) => {
|
|
if (importingPosts.value) {
|
|
return false
|
|
}
|
|
|
|
if (!file) {
|
|
return false
|
|
}
|
|
|
|
if (!file.name.toLowerCase().endsWith('.zip')) {
|
|
showToast('error', 'ZIP 파일만 가져올 수 있습니다.')
|
|
return false
|
|
}
|
|
|
|
postImportFile.value = file
|
|
postImportFileName.value = file.name
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* 선택된 게시물 Import 파일을 업로드한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const importSelectedPostFile = async () => {
|
|
if (importingPosts.value) {
|
|
return
|
|
}
|
|
|
|
if (!postImportFile.value) {
|
|
showToast('error', '가져올 ZIP 파일을 먼저 선택해 주세요.')
|
|
return
|
|
}
|
|
|
|
importingPosts.value = true
|
|
showToast('info', '게시물 가져오기를 시작합니다.')
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', postImportFile.value)
|
|
|
|
const job = await $fetch('/admin/api/posts/import', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
|
|
selectedPostImportJobId.value = job.id
|
|
isPostImportJobModalOpen.value = true
|
|
postImportFile.value = null
|
|
postImportFileName.value = ''
|
|
await refreshPostImportJobs()
|
|
showToast('success', '게시물 가져오기 작업을 시작했습니다.')
|
|
} catch (error) {
|
|
const message = error?.data?.message || '게시물 가져오기 작업을 시작하지 못했습니다.'
|
|
showToast('error', message)
|
|
} finally {
|
|
importingPosts.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import 작업 센터를 연다.
|
|
* @param {Object|null} job - 선택 작업
|
|
* @returns {void}
|
|
*/
|
|
const openPostImportJobModal = (job = null) => {
|
|
selectedPostImportJobId.value = job?.id || normalizedPostImportJobs.value[0]?.id || ''
|
|
isPostImportJobModalOpen.value = true
|
|
}
|
|
|
|
/**
|
|
* Import 작업 센터를 닫는다.
|
|
* @returns {void}
|
|
*/
|
|
const closePostImportJobModal = () => {
|
|
isPostImportJobModalOpen.value = false
|
|
}
|
|
|
|
/**
|
|
* Import 작업 삭제 중 여부를 확인한다.
|
|
* @param {string} jobId - 작업 ID
|
|
* @returns {boolean} 삭제 중 여부
|
|
*/
|
|
const isDeletingPostImportJob = (jobId) => deletingPostImportJobIds.value.includes(jobId)
|
|
|
|
/**
|
|
* Import 작업을 삭제한다.
|
|
* @param {Object} job - Import 작업
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const deletePostImportJob = async (job) => {
|
|
if (!job?.id || isDeletingPostImportJob(job.id)) {
|
|
return
|
|
}
|
|
|
|
deletingPostImportJobIds.value = [...deletingPostImportJobIds.value, job.id]
|
|
showToast('info', '가져오기 작업 기록을 삭제하는 중입니다.')
|
|
|
|
try {
|
|
await $fetch(`/admin/api/posts/import-jobs/${job.id}`, {
|
|
method: 'DELETE'
|
|
})
|
|
await refreshPostImportJobs()
|
|
if (selectedPostImportJobId.value === job.id) {
|
|
selectedPostImportJobId.value = normalizedPostImportJobs.value[0]?.id || ''
|
|
}
|
|
showToast('success', '가져오기 작업 기록을 삭제했습니다.')
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '가져오기 작업 기록을 삭제하지 못했습니다.'
|
|
showToast('error', errorMessage.value)
|
|
} finally {
|
|
deletingPostImportJobIds.value = deletingPostImportJobIds.value.filter((id) => id !== job.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export 파일 다운로드 URL을 만든다.
|
|
* @param {Object} file - Export 파일
|
|
* @returns {string} 다운로드 URL
|
|
*/
|
|
const getPostExportDownloadUrl = (file) => `/admin/api/posts/export-jobs/${file.id}/download`
|
|
|
|
/**
|
|
* Export 작업의 다운로드 가능한 파일만 추린다.
|
|
* @param {Object} job - Export 작업
|
|
* @returns {Array} 다운로드 가능한 파일 목록
|
|
*/
|
|
const getReadyPostExportFiles = (job) => Array.isArray(job?.files)
|
|
? job.files.filter((file) => file.status === 'ready' && file.filePath)
|
|
: []
|
|
|
|
/**
|
|
* Export 작업의 선택된 파일 ID를 가져온다.
|
|
* @param {string} jobId - 작업 ID
|
|
* @returns {Array<string>} 선택된 파일 ID 목록
|
|
*/
|
|
const getSelectedPostExportFileIds = (jobId) => Array.isArray(selectedPostExportFileIds.value[jobId])
|
|
? selectedPostExportFileIds.value[jobId]
|
|
: []
|
|
|
|
/**
|
|
* Export 파일이 선택되었는지 확인한다.
|
|
* @param {Object} job - Export 작업
|
|
* @param {Object} file - Export 파일
|
|
* @returns {boolean} 선택 여부
|
|
*/
|
|
const isPostExportFileSelected = (job, file) => getSelectedPostExportFileIds(job?.id).includes(file?.id)
|
|
|
|
/**
|
|
* Export 파일 선택 비활성 여부를 확인한다.
|
|
* @param {Object} job - Export 작업
|
|
* @param {Object} file - Export 파일
|
|
* @returns {boolean} 비활성 여부
|
|
*/
|
|
const isPostExportFileSelectionDisabled = (job, file) => file.status !== 'ready'
|
|
|| !file.filePath
|
|
|| isDownloadingPostExportJob(job.id)
|
|
|
|
/**
|
|
* Export 파일 선택 상태를 변경한다.
|
|
* @param {Object} job - Export 작업
|
|
* @param {Object} file - Export 파일
|
|
* @param {boolean} selected - 선택 여부
|
|
* @returns {void}
|
|
*/
|
|
const setPostExportFileSelected = (job, file, selected) => {
|
|
if (!job?.id || !file?.id || file.status !== 'ready' || !file.filePath) {
|
|
return
|
|
}
|
|
|
|
const currentIds = getSelectedPostExportFileIds(job.id)
|
|
const nextIds = selected
|
|
? [...new Set([...currentIds, file.id])]
|
|
: currentIds.filter((fileId) => fileId !== file.id)
|
|
|
|
selectedPostExportFileIds.value = {
|
|
...selectedPostExportFileIds.value,
|
|
[job.id]: nextIds
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export 파일 선택 상태를 반대로 전환한다.
|
|
* @param {Object} job - Export 작업
|
|
* @param {Object} file - Export 파일
|
|
* @returns {void}
|
|
*/
|
|
const togglePostExportFileSelection = (job, file) => {
|
|
setPostExportFileSelected(job, file, !isPostExportFileSelected(job, file))
|
|
}
|
|
|
|
/**
|
|
* Export 작업에서 선택된 다운로드 가능 파일을 가져온다.
|
|
* @param {Object} job - Export 작업
|
|
* @returns {Array} 선택된 다운로드 가능 파일 목록
|
|
*/
|
|
const getSelectedReadyPostExportFiles = (job) => {
|
|
const selectedIds = getSelectedPostExportFileIds(job?.id)
|
|
|
|
return getReadyPostExportFiles(job).filter((file) => selectedIds.includes(file.id))
|
|
}
|
|
|
|
/**
|
|
* Export 작업의 모든 다운로드 가능 파일이 선택되었는지 확인한다.
|
|
* @param {Object} job - Export 작업
|
|
* @returns {boolean} 전체 선택 여부
|
|
*/
|
|
const areAllReadyPostExportFilesSelected = (job) => {
|
|
const readyFiles = getReadyPostExportFiles(job)
|
|
const selectedIds = getSelectedPostExportFileIds(job?.id)
|
|
|
|
return readyFiles.length > 0 && readyFiles.every((file) => selectedIds.includes(file.id))
|
|
}
|
|
|
|
/**
|
|
* Export 작업의 다운로드 가능 파일 전체 선택을 전환한다.
|
|
* @param {Object} job - Export 작업
|
|
* @returns {void}
|
|
*/
|
|
const toggleAllPostExportFiles = (job) => {
|
|
if (!job?.id) {
|
|
return
|
|
}
|
|
|
|
const readyFiles = getReadyPostExportFiles(job)
|
|
const nextIds = areAllReadyPostExportFilesSelected(job)
|
|
? []
|
|
: readyFiles.map((file) => file.id)
|
|
|
|
selectedPostExportFileIds.value = {
|
|
...selectedPostExportFileIds.value,
|
|
[job.id]: nextIds
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export 작업의 파일 선택을 비운다.
|
|
* @param {string} jobId - 작업 ID
|
|
* @returns {void}
|
|
*/
|
|
const clearSelectedPostExportFiles = (jobId) => {
|
|
selectedPostExportFileIds.value = {
|
|
...selectedPostExportFileIds.value,
|
|
[jobId]: []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export 진행도 영역 표시 여부를 확인한다.
|
|
* @param {Object} job - Export 작업
|
|
* @returns {boolean} 진행도 표시 여부
|
|
*/
|
|
const shouldShowPostExportProgress = (job) => job?.status === 'queued' || job?.status === 'processing'
|
|
|
|
/**
|
|
* Export 다운로드 중 여부
|
|
* @param {string} jobId - 작업 ID
|
|
* @returns {boolean} 다운로드 중 여부
|
|
*/
|
|
const isDownloadingPostExportJob = (jobId) => downloadingPostExportJobIds.value.includes(jobId)
|
|
|
|
/**
|
|
* Export 작업 재시도 중 여부
|
|
* @param {string} jobId - 작업 ID
|
|
* @returns {boolean} 재시도 중 여부
|
|
*/
|
|
const isRetryingPostExportJob = (jobId) => retryingPostExportJobIds.value.includes(jobId)
|
|
|
|
/**
|
|
* 선택한 Export 분할 파일을 브라우저에서 순차 다운로드한다.
|
|
* @param {Object} job - Export 작업
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const downloadPostExportJobFiles = async (job) => {
|
|
const selectedFiles = getSelectedReadyPostExportFiles(job)
|
|
|
|
if (!job?.id || isDownloadingPostExportJob(job.id)) {
|
|
return
|
|
}
|
|
|
|
if (selectedFiles.length === 0) {
|
|
showToast('error', '다운로드할 파일을 선택해 주세요.')
|
|
return
|
|
}
|
|
|
|
downloadingPostExportJobIds.value = [...downloadingPostExportJobIds.value, job.id]
|
|
showToast('info', `${selectedFiles.length}개 선택 파일을 순차 다운로드합니다.`)
|
|
|
|
try {
|
|
for (const file of selectedFiles) {
|
|
const link = document.createElement('a')
|
|
link.href = getPostExportDownloadUrl(file)
|
|
link.download = file.fileName || ''
|
|
link.rel = 'noopener'
|
|
document.body.append(link)
|
|
link.click()
|
|
link.remove()
|
|
await new Promise((resolve) => window.setTimeout(resolve, 700))
|
|
}
|
|
clearSelectedPostExportFiles(job.id)
|
|
showToast('success', '선택 파일 다운로드 요청을 보냈습니다.')
|
|
} finally {
|
|
downloadingPostExportJobIds.value = downloadingPostExportJobIds.value.filter((id) => id !== job.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 실패한 Export 작업을 재시도한다.
|
|
* @param {Object} job - Export 작업
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const retryPostExportJob = async (job) => {
|
|
if (!job?.id || job.status !== 'failed' || isRetryingPostExportJob(job.id)) {
|
|
return
|
|
}
|
|
|
|
retryingPostExportJobIds.value = [...retryingPostExportJobIds.value, job.id]
|
|
showToast('info', '실패한 내보내기 작업을 다시 대기열에 등록하는 중입니다.')
|
|
|
|
try {
|
|
await $fetch(`/admin/api/posts/export-jobs/${job.id}/retry`, {
|
|
method: 'POST'
|
|
})
|
|
await refreshPostExportJobs()
|
|
showToast('success', '내보내기 작업을 다시 시작했습니다.')
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '내보내기 작업을 다시 시작하지 못했습니다.'
|
|
showToast('error', errorMessage.value)
|
|
} finally {
|
|
retryingPostExportJobIds.value = retryingPostExportJobIds.value.filter((id) => id !== job.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export 작업 삭제 중 여부
|
|
* @param {string} jobId - 작업 ID
|
|
* @returns {boolean} 삭제 중 여부
|
|
*/
|
|
const isDeletingPostExportJob = (jobId) => deletingPostExportJobIds.value.includes(jobId)
|
|
|
|
/**
|
|
* Export 작업을 삭제한다.
|
|
* @param {Object} job - Export 작업
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const deletePostExportJob = async (job) => {
|
|
if (!job?.id || isDeletingPostExportJob(job.id)) {
|
|
return
|
|
}
|
|
|
|
deletingPostExportJobIds.value = [...deletingPostExportJobIds.value, job.id]
|
|
showToast('info', '내보내기 백업 파일을 삭제하는 중입니다.')
|
|
|
|
try {
|
|
await $fetch(`/admin/api/posts/export-jobs/${job.id}`, {
|
|
method: 'DELETE'
|
|
})
|
|
await refreshPostExportJobs()
|
|
showToast('success', '내보내기 백업 파일을 삭제했습니다.')
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '내보내기 백업 파일을 삭제하지 못했습니다.'
|
|
showToast('error', errorMessage.value)
|
|
} finally {
|
|
deletingPostExportJobIds.value = deletingPostExportJobIds.value.filter((id) => id !== job.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사이트 로고를 업로드한다.
|
|
* @param {Event} event - 파일 선택 이벤트
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const uploadLogo = async (event) => {
|
|
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
|
|
const file = target?.files?.[0]
|
|
|
|
if (!file || uploadingLogo.value) {
|
|
return
|
|
}
|
|
|
|
uploadingLogo.value = true
|
|
errorMessage.value = ''
|
|
showToast('info', '로고를 업로드하는 중입니다.')
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
const uploadedLogo = await $fetch('/admin/api/settings/logo', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
form.logoUrl = uploadedLogo.logoUrl || ''
|
|
form.faviconUrl = uploadedLogo.faviconUrl || ''
|
|
showToast('success', '로고를 불러왔습니다. 저장 버튼을 눌러 적용하세요.')
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '로고 업로드에 실패했습니다.'
|
|
showToast('error', errorMessage.value)
|
|
} finally {
|
|
uploadingLogo.value = false
|
|
if (target) {
|
|
target.value = ''
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사이트 설정 PUT 요청 본문을 구성한다.
|
|
* @param {Array<string>|null} [fields] - 포함할 필드 목록
|
|
* @returns {Object} API 본문
|
|
*/
|
|
const buildSiteSettingsPayload = (fields = null) => {
|
|
const payload = {
|
|
title: form.title,
|
|
description: form.description,
|
|
siteUrl: form.siteUrl,
|
|
logoText: form.logoText || '井',
|
|
logoUrl: form.logoUrl,
|
|
faviconUrl: form.faviconUrl,
|
|
copyrightText: form.copyrightText,
|
|
...normalizeSocialSettingsSection(form),
|
|
...normalizePostSettingsSection(form),
|
|
homeCoverImageUrl: form.homeCoverImageUrl || '',
|
|
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
|
|
homeCoverTitle: form.homeCoverTitle || '',
|
|
homeCoverText: form.homeCoverText || '',
|
|
...normalizeBrandSettingsSection(form),
|
|
...normalizeAnnouncementSettingsSection(form),
|
|
...normalizeSpamSettingsSection(form),
|
|
adsTxt: form.adsTxt || '',
|
|
customHeadCode: form.customHeadCode || '',
|
|
customFooterCode: form.customFooterCode || '',
|
|
adHomeFeedCode: form.adHomeFeedCode || '',
|
|
adHomeInfeedCode: form.adHomeInfeedCode || '',
|
|
adSidebarCode: form.adSidebarCode || '',
|
|
adPostSidebarCode: form.adPostSidebarCode || '',
|
|
adPostTopCode: form.adPostTopCode || '',
|
|
adPostInArticleCode: form.adPostInArticleCode || '',
|
|
adPostBottomCode: form.adPostBottomCode || ''
|
|
}
|
|
|
|
if (!Array.isArray(fields)) {
|
|
return payload
|
|
}
|
|
|
|
return Object.fromEntries(fields.map((field) => [field, payload[field]]))
|
|
}
|
|
|
|
/**
|
|
* 현재 폼 값으로 사이트 설정을 서버에 저장한다.
|
|
* @param {{ successToast?: string, savingFlag: import('vue').Ref<boolean>, fields: Array<string> }} options - 저장 옵션
|
|
* @returns {Promise<boolean>} 성공 여부
|
|
*/
|
|
const persistSiteSettings = async (options) => {
|
|
const successToast = options.successToast || '사이트 설정이 저장되었습니다.'
|
|
options.savingFlag.value = true
|
|
errorMessage.value = ''
|
|
showToast('info', '설정을 저장하는 중입니다.')
|
|
|
|
try {
|
|
const payload = buildSiteSettingsPayload(options.fields)
|
|
const updatedSettings = await $fetch('/admin/api/settings', {
|
|
method: 'PATCH',
|
|
body: payload
|
|
})
|
|
options.fields.forEach((field) => {
|
|
form[field] = updatedSettings[field]
|
|
})
|
|
showToast('success', successToast)
|
|
return true
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '사이트 설정을 저장하지 못했습니다.'
|
|
showToast('error', errorMessage.value)
|
|
return false
|
|
} finally {
|
|
options.savingFlag.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정한 필드를 원본 객체에서 대상 객체로 복사한다.
|
|
* @param {Object} target - 값을 받을 객체
|
|
* @param {Object} source - 값을 제공할 객체
|
|
* @param {Array<string>} fields - 복사할 필드 목록
|
|
* @returns {void}
|
|
*/
|
|
const copySettingsFields = (target, source, fields) => {
|
|
fields.forEach((field) => {
|
|
target[field] = source[field]
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 단순 설정 섹션 저장 후 스냅샷 갱신과 편집 종료를 처리한다.
|
|
* @param {{ hasChanges: import('vue').ComputedRef<boolean>, savingFlag: import('vue').Ref<boolean>, editingFlag: import('vue').Ref<boolean>, snapshot: Object, fields: Array<string>, successToast: string, beforeSave?: Function, afterSnapshot?: Function }} options - 섹션 저장 옵션
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveSimpleSettingsSection = async (options) => {
|
|
if (typeof options.beforeSave === 'function') {
|
|
options.beforeSave()
|
|
}
|
|
|
|
if (!options.hasChanges.value) {
|
|
return
|
|
}
|
|
|
|
const ok = await persistSiteSettings({
|
|
successToast: options.successToast,
|
|
savingFlag: options.savingFlag,
|
|
fields: options.fields
|
|
})
|
|
|
|
if (!ok) {
|
|
return
|
|
}
|
|
|
|
copySettingsFields(options.snapshot, form, options.fields)
|
|
if (typeof options.afterSnapshot === 'function') {
|
|
options.afterSnapshot()
|
|
}
|
|
options.editingFlag.value = false
|
|
}
|
|
|
|
/**
|
|
* 블로그 제목·설명 편집 모드 진입
|
|
* @returns {void}
|
|
*/
|
|
const beginEditTitleDesc = () => {
|
|
copySettingsFields(titleDescSnapshot, form, titleDescFields)
|
|
editTitleDesc.value = true
|
|
}
|
|
|
|
/**
|
|
* 블로그 제목·설명 편집 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelEditTitleDesc = () => {
|
|
copySettingsFields(form, titleDescSnapshot, titleDescFields)
|
|
editTitleDesc.value = false
|
|
}
|
|
|
|
/**
|
|
* 블로그 제목·설명만 저장하고 읽기 모드로 돌아간다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveTitleDescSection = async () => {
|
|
await saveSimpleSettingsSection({
|
|
hasChanges: hasTitleDescChanges,
|
|
successToast: '블로그 제목·설명이 저장되었습니다.',
|
|
savingFlag: savingTitleDesc,
|
|
editingFlag: editTitleDesc,
|
|
snapshot: titleDescSnapshot,
|
|
fields: titleDescFields
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 사이트 정보 편집 모드 진입
|
|
* @returns {void}
|
|
*/
|
|
const beginEditMisc = () => {
|
|
copySettingsFields(miscSnapshot, form, miscFields)
|
|
editMisc.value = true
|
|
}
|
|
|
|
/**
|
|
* 사이트 정보 편집 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelEditMisc = () => {
|
|
copySettingsFields(form, miscSnapshot, miscFields)
|
|
editMisc.value = false
|
|
}
|
|
|
|
/**
|
|
* 사이트 정보 저장
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveMiscSection = async () => {
|
|
await saveSimpleSettingsSection({
|
|
hasChanges: hasMiscChanges,
|
|
successToast: '사이트 정보가 저장되었습니다.',
|
|
savingFlag: savingMisc,
|
|
editingFlag: editMisc,
|
|
snapshot: miscSnapshot,
|
|
fields: miscFields
|
|
})
|
|
}
|
|
|
|
/**
|
|
* SNS 정보 편집 모드 진입
|
|
* @returns {void}
|
|
*/
|
|
const beginEditSocial = () => {
|
|
assignSocialSettingsSection(socialSnapshot, form)
|
|
assignSocialSettingsSection(form, socialSnapshot)
|
|
editSocial.value = true
|
|
}
|
|
|
|
/**
|
|
* SNS 정보 편집 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelEditSocial = () => {
|
|
assignSocialSettingsSection(form, socialSnapshot)
|
|
editSocial.value = false
|
|
}
|
|
|
|
/**
|
|
* SNS 정보를 저장한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveSocialSection = async () => {
|
|
if (!hasSocialChanges.value) {
|
|
return
|
|
}
|
|
|
|
assignSocialSettingsSection(form, form)
|
|
|
|
const ok = await persistSiteSettings({
|
|
successToast: 'SNS 정보가 저장되었습니다.',
|
|
savingFlag: savingSocial,
|
|
fields: SOCIAL_SETTINGS_FIELDS
|
|
})
|
|
|
|
if (ok) {
|
|
assignSocialSettingsSection(socialSnapshot, form)
|
|
assignSocialSettingsSection(form, socialSnapshot)
|
|
editSocial.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST 설정 편집 모드 진입
|
|
* @returns {void}
|
|
*/
|
|
const beginEditPost = () => {
|
|
assignPostSettingsSection(postSnapshot, form)
|
|
assignPostSettingsSection(form, postSnapshot)
|
|
editPost.value = true
|
|
}
|
|
|
|
/**
|
|
* POST 설정 편집 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelEditPost = () => {
|
|
assignPostSettingsSection(form, postSnapshot)
|
|
editPost.value = false
|
|
}
|
|
|
|
/**
|
|
* POST 설정 저장
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const savePostSection = async () => {
|
|
if (!hasPostChanges.value) {
|
|
return
|
|
}
|
|
|
|
const ok = await persistSiteSettings({
|
|
successToast: 'POST 설정이 저장되었습니다.',
|
|
savingFlag: savingPost,
|
|
fields: POST_SETTINGS_FIELDS
|
|
})
|
|
|
|
if (ok) {
|
|
assignPostSettingsSection(postSnapshot, form)
|
|
assignPostSettingsSection(form, postSnapshot)
|
|
editPost.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 메인 화면 커버 이미지 파일을 업로드한다.
|
|
* @param {File} file - 업로드 파일
|
|
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const uploadHomeCoverFile = async (file, variant = 'light') => {
|
|
const uploadingFlag = variant === 'dark' ? uploadingHomeCoverDark : uploadingHomeCover
|
|
|
|
if (!file || uploadingFlag.value) {
|
|
return
|
|
}
|
|
|
|
uploadingFlag.value = true
|
|
errorMessage.value = ''
|
|
showToast('info', '커버 이미지를 업로드하는 중입니다.')
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
const { homeCoverImageUrl } = await $fetch('/admin/api/settings/home-cover', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
if (variant === 'dark') {
|
|
form.homeCoverDarkImageUrl = homeCoverImageUrl || ''
|
|
} else {
|
|
form.homeCoverImageUrl = homeCoverImageUrl || ''
|
|
}
|
|
showToast('success', '커버 이미지를 불러왔습니다. 저장 버튼을 눌러 적용하세요.')
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '커버 이미지 업로드에 실패했습니다.'
|
|
showToast('error', errorMessage.value)
|
|
} finally {
|
|
uploadingFlag.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 메인 화면 커버 이미지를 업로드한다.
|
|
* @param {Event} event - 파일 선택 이벤트
|
|
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const uploadHomeCover = async (event, variant = 'light') => {
|
|
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
|
|
const file = target?.files?.[0]
|
|
|
|
if (!file) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await uploadHomeCoverFile(file, variant)
|
|
} finally {
|
|
if (target) {
|
|
target.value = ''
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 드롭된 메인 화면 커버 이미지를 업로드한다.
|
|
* @param {DragEvent} event - 드롭 이벤트
|
|
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const dropHomeCover = async (event, variant = 'light') => {
|
|
const file = event.dataTransfer?.files?.[0]
|
|
|
|
if (!file) {
|
|
return
|
|
}
|
|
|
|
await uploadHomeCoverFile(file, variant)
|
|
}
|
|
|
|
/**
|
|
* 메인 화면 커버 이미지를 제거한다.
|
|
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
|
* @returns {void}
|
|
*/
|
|
const clearHomeCoverImage = (variant = 'light') => {
|
|
if (variant === 'dark') {
|
|
form.homeCoverDarkImageUrl = ''
|
|
return
|
|
}
|
|
|
|
form.homeCoverImageUrl = ''
|
|
}
|
|
|
|
/**
|
|
* 메인 화면 커버 편집 모드 진입
|
|
* @returns {void}
|
|
*/
|
|
const beginEditHomeCover = () => {
|
|
copySettingsFields(homeCoverSnapshot, form, homeCoverFields)
|
|
editHomeCover.value = true
|
|
}
|
|
|
|
/**
|
|
* 메인 화면 커버 편집 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelEditHomeCover = () => {
|
|
copySettingsFields(form, homeCoverSnapshot, homeCoverFields)
|
|
editHomeCover.value = false
|
|
}
|
|
|
|
/**
|
|
* 메인 화면 커버 저장
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveHomeCoverSection = async () => {
|
|
await saveSimpleSettingsSection({
|
|
hasChanges: hasHomeCoverChanges,
|
|
successToast: '메인 화면 설정이 저장되었습니다.',
|
|
savingFlag: savingHomeCover,
|
|
editingFlag: editHomeCover,
|
|
snapshot: homeCoverSnapshot,
|
|
fields: homeCoverFields
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 브랜드 설정 편집 모드 진입
|
|
* @returns {void}
|
|
*/
|
|
const beginEditBrand = () => {
|
|
assignBrandSettingsSection(brandSnapshot, form)
|
|
assignBrandSettingsSection(form, brandSnapshot)
|
|
editBrand.value = true
|
|
}
|
|
|
|
/**
|
|
* 브랜드 설정 편집 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelEditBrand = () => {
|
|
assignBrandSettingsSection(form, brandSnapshot)
|
|
editBrand.value = false
|
|
}
|
|
|
|
/**
|
|
* 브랜드 컬러 입력값을 정규화한다.
|
|
* @returns {void}
|
|
*/
|
|
const normalizeBrandColorInput = () => {
|
|
assignBrandSettingsSection(form, form)
|
|
}
|
|
|
|
/**
|
|
* 브랜드 설정 저장
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveBrandSection = async () => {
|
|
normalizeBrandColorInput()
|
|
|
|
if (!hasBrandChanges.value) {
|
|
return
|
|
}
|
|
|
|
const ok = await persistSiteSettings({
|
|
successToast: '브랜드 컬러가 저장되었습니다.',
|
|
savingFlag: savingBrand,
|
|
fields: BRAND_SETTINGS_FIELDS
|
|
})
|
|
|
|
if (ok) {
|
|
assignBrandSettingsSection(brandSnapshot, form)
|
|
assignBrandSettingsSection(form, brandSnapshot)
|
|
editBrand.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 어나운스 바 맞춤 설정 패널을 연다.
|
|
* @returns {void}
|
|
*/
|
|
const beginCustomizeAnnouncement = () => {
|
|
assignAnnouncementSettingsSection(announcementSnapshot, form)
|
|
assignAnnouncementSettingsSection(form, announcementSnapshot)
|
|
customizeAnnouncement.value = true
|
|
}
|
|
|
|
/**
|
|
* 어나운스 바 맞춤 설정을 취소한다.
|
|
* @returns {void}
|
|
*/
|
|
const cancelCustomizeAnnouncement = () => {
|
|
assignAnnouncementSettingsSection(form, announcementSnapshot)
|
|
customizeAnnouncement.value = false
|
|
}
|
|
|
|
/**
|
|
* 어나운스 바 설정을 저장한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveAnnouncementSection = async () => {
|
|
if (!hasAnnouncementChanges.value) {
|
|
return
|
|
}
|
|
|
|
const ok = await persistSiteSettings({
|
|
successToast: '어나운스 바 설정이 저장되었습니다.',
|
|
savingFlag: savingAnnouncement,
|
|
fields: ANNOUNCEMENT_SETTINGS_FIELDS
|
|
})
|
|
|
|
if (ok) {
|
|
assignAnnouncementSettingsSection(announcementSnapshot, form)
|
|
assignAnnouncementSettingsSection(form, announcementSnapshot)
|
|
customizeAnnouncement.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스팸 필터 편집 모드 진입
|
|
* @returns {void}
|
|
*/
|
|
const beginEditSpam = () => {
|
|
assignSpamSettingsSection(spamSnapshot, form)
|
|
assignSpamSettingsSection(form, spamSnapshot)
|
|
editSpam.value = true
|
|
}
|
|
|
|
/**
|
|
* 스팸 필터 편집 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelEditSpam = () => {
|
|
assignSpamSettingsSection(form, spamSnapshot)
|
|
editSpam.value = false
|
|
}
|
|
|
|
/**
|
|
* 스팸 필터 저장
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveSpamSection = async () => {
|
|
if (!hasSpamChanges.value) {
|
|
return
|
|
}
|
|
|
|
const ok = await persistSiteSettings({
|
|
successToast: '스팸 필터 설정이 저장되었습니다.',
|
|
savingFlag: savingSpam,
|
|
fields: SPAM_SETTINGS_FIELDS
|
|
})
|
|
|
|
if (ok) {
|
|
assignSpamSettingsSection(spamSnapshot, form)
|
|
assignSpamSettingsSection(form, spamSnapshot)
|
|
editSpam.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사이트 코드 편집 모드 진입
|
|
* @returns {void}
|
|
*/
|
|
const beginEditSiteCode = () => {
|
|
copySettingsFields(siteCodeSnapshot, form, siteCodeFields)
|
|
editSiteCode.value = true
|
|
}
|
|
|
|
/**
|
|
* 사이트 코드 편집 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelEditSiteCode = () => {
|
|
copySettingsFields(form, siteCodeSnapshot, siteCodeFields)
|
|
editSiteCode.value = false
|
|
}
|
|
|
|
/**
|
|
* 사이트 코드 저장
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveSiteCodeSection = async () => {
|
|
await saveSimpleSettingsSection({
|
|
hasChanges: hasSiteCodeChanges,
|
|
successToast: '사이트 코드 설정이 저장되었습니다.',
|
|
savingFlag: savingSiteCode,
|
|
editingFlag: editSiteCode,
|
|
snapshot: siteCodeSnapshot,
|
|
fields: siteCodeFields
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 광고 슬롯 편집 모드 진입
|
|
* @returns {void}
|
|
*/
|
|
const beginEditAds = () => {
|
|
copySettingsFields(adsSnapshot, form, adsFields)
|
|
editAds.value = true
|
|
}
|
|
|
|
/**
|
|
* 광고 슬롯 편집 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelEditAds = () => {
|
|
copySettingsFields(form, adsSnapshot, adsFields)
|
|
editAds.value = false
|
|
}
|
|
|
|
/**
|
|
* 광고 슬롯 설정 저장
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveAdsSection = async () => {
|
|
await saveSimpleSettingsSection({
|
|
hasChanges: hasAdsChanges,
|
|
successToast: '광고 슬롯 설정이 저장되었습니다.',
|
|
savingFlag: savingAds,
|
|
editingFlag: editAds,
|
|
snapshot: adsSnapshot,
|
|
fields: adsFields
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Escape 키: 제목·설명 편집 중이면 취소, 아니면 설정 화면 닫기
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onGlobalKeydown = (event) => {
|
|
if (event.key !== 'Escape') {
|
|
return
|
|
}
|
|
if (editTitleDesc.value) {
|
|
event.preventDefault()
|
|
cancelEditTitleDesc()
|
|
return
|
|
}
|
|
if (editMisc.value) {
|
|
event.preventDefault()
|
|
cancelEditMisc()
|
|
return
|
|
}
|
|
if (editPost.value) {
|
|
event.preventDefault()
|
|
cancelEditPost()
|
|
return
|
|
}
|
|
if (editHomeCover.value) {
|
|
event.preventDefault()
|
|
cancelEditHomeCover()
|
|
return
|
|
}
|
|
if (customizeAnnouncement.value) {
|
|
event.preventDefault()
|
|
cancelCustomizeAnnouncement()
|
|
return
|
|
}
|
|
if (editSpam.value) {
|
|
event.preventDefault()
|
|
cancelEditSpam()
|
|
return
|
|
}
|
|
if (editSiteCode.value) {
|
|
event.preventDefault()
|
|
cancelEditSiteCode()
|
|
return
|
|
}
|
|
if (editAds.value) {
|
|
event.preventDefault()
|
|
cancelEditAds()
|
|
return
|
|
}
|
|
closeSettings()
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (import.meta.client) {
|
|
window.addEventListener('keydown', onGlobalKeydown)
|
|
postExportRefreshTimer = window.setInterval(() => {
|
|
if (hasActivePostExportJobs.value) {
|
|
refreshPostExportJobs()
|
|
}
|
|
}, 5000)
|
|
postImportRefreshTimer = window.setInterval(() => {
|
|
if (hasActivePostImportJobs.value || isPostImportJobModalOpen.value) {
|
|
refreshPostImportJobs()
|
|
}
|
|
}, 2000)
|
|
nextTick(() => {
|
|
refreshActiveSection()
|
|
})
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.clearInterval(postExportRefreshTimer)
|
|
window.clearInterval(postImportRefreshTimer)
|
|
cleanupSettingsNavigation()
|
|
if (import.meta.client) {
|
|
window.removeEventListener('keydown', onGlobalKeydown)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="admin-settings-screen flex h-full min-h-0 flex-col bg-[#f7f8fa] text-[#15171a]">
|
|
<div
|
|
id="admin-settings-done-button-container"
|
|
class="pointer-events-none fixed right-0 top-2 z-50 flex justify-end bg-transparent p-8 md:top-0 md:px-8"
|
|
>
|
|
<button
|
|
id="admin-settings-done-button"
|
|
class="pointer-events-auto inline-flex cursor-pointer items-center justify-center rounded text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:text-[#15171a]"
|
|
type="button"
|
|
title="닫기 (ESC)"
|
|
aria-label="설정 닫기"
|
|
@click="closeSettings"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="pointer-events-none size-5" fill="none" aria-hidden="true">
|
|
<line x1="0.75" y1="23.249" x2="23.25" y2="0.749" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
<line x1="23.25" y1="23.249" x2="0.75" y2="0.749" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="admin-settings-screen__shell flex min-h-0 flex-1 justify-center overflow-hidden px-0 sm:px-4 lg:px-6">
|
|
<div class="admin-settings-screen__body flex min-h-0 w-full max-w-[1120px] flex-1 flex-col lg:flex-row">
|
|
<AdminSettingsNavigation
|
|
:search-value="navSearchInput"
|
|
:filtered-groups="filteredSettingsNavGroups"
|
|
:groups="settingsNavGroups"
|
|
:active-section-id="activeSectionId"
|
|
@search-input="onNavSearchInput"
|
|
@search-composition-end="onNavSearchCompositionEnd"
|
|
@navigate="scrollToSection"
|
|
/>
|
|
|
|
<main
|
|
ref="mainScrollRef"
|
|
class="admin-settings-screen__content min-h-0 min-w-0 flex-1 overflow-y-auto bg-white lg:border-l lg:border-[#e6e8eb]"
|
|
@scroll.passive="onMainScroll"
|
|
>
|
|
<div class="admin-settings-screen__content-inner mx-auto mb-[60vh] w-full max-w-[760px] px-8 pt-16 pb-24 md:px-14 md:pt-10">
|
|
<p v-if="errorMessage" class="admin-settings-screen__error mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
{{ errorMessage }}
|
|
</p>
|
|
|
|
<form class="admin-settings-screen__form w-full space-y-8" @submit.prevent>
|
|
<h2 class="admin-settings-screen__section-heading z-20 -mt-[5px] mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
|
|
일반 설정
|
|
</h2>
|
|
<AdminTitleDescriptionSettingsCard
|
|
:form="form"
|
|
:editing="editTitleDesc"
|
|
:saving="savingTitleDesc"
|
|
:has-changes="hasTitleDescChanges"
|
|
@begin="beginEditTitleDesc"
|
|
@cancel="cancelEditTitleDesc"
|
|
@save="saveTitleDescSection"
|
|
/>
|
|
|
|
<AdminSiteInfoSettingsCard
|
|
:form="form"
|
|
:editing="editMisc"
|
|
:saving="savingMisc"
|
|
:uploading-logo="uploadingLogo"
|
|
:has-changes="hasMiscChanges"
|
|
@begin="beginEditMisc"
|
|
@cancel="cancelEditMisc"
|
|
@save="saveMiscSection"
|
|
@upload-logo="uploadLogo"
|
|
/>
|
|
|
|
<AdminSocialSettingsCard
|
|
:form="form"
|
|
:editing="editSocial"
|
|
:saving="savingSocial"
|
|
:has-changes="hasSocialChanges"
|
|
@begin="beginEditSocial"
|
|
@cancel="cancelEditSocial"
|
|
@save="saveSocialSection"
|
|
/>
|
|
|
|
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
|
|
게시물
|
|
</h2>
|
|
<AdminPostSettingsCard
|
|
:form="form"
|
|
:editing="editPost"
|
|
:saving="savingPost"
|
|
:has-changes="hasPostChanges"
|
|
:post-list-count-options="postListCountSelectOptions"
|
|
:display-count-options="displayCountSelectOptions"
|
|
@begin="beginEditPost"
|
|
@cancel="cancelEditPost"
|
|
@save="savePostSection"
|
|
/>
|
|
|
|
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
|
|
사이트
|
|
</h2>
|
|
<AdminHomeCoverSettingsCard
|
|
:form="form"
|
|
:editing="editHomeCover"
|
|
:saving="savingHomeCover"
|
|
:uploading-light="uploadingHomeCover"
|
|
:uploading-dark="uploadingHomeCoverDark"
|
|
:has-changes="hasHomeCoverChanges"
|
|
@begin="beginEditHomeCover"
|
|
@cancel="cancelEditHomeCover"
|
|
@save="saveHomeCoverSection"
|
|
@upload-cover="uploadHomeCover"
|
|
@drop-cover="dropHomeCover"
|
|
@clear-cover="clearHomeCoverImage"
|
|
/>
|
|
|
|
<AdminBrandSettingsCard
|
|
v-model:brand-color="form.brandColor"
|
|
:editing="editBrand"
|
|
:saving="savingBrand"
|
|
:has-changes="hasBrandChanges"
|
|
@begin="beginEditBrand"
|
|
@cancel="cancelEditBrand"
|
|
@save="saveBrandSection"
|
|
@normalize="normalizeBrandColorInput"
|
|
/>
|
|
|
|
<AdminAnnouncementSettingsCard
|
|
:form="form"
|
|
:editing="customizeAnnouncement"
|
|
:saving="savingAnnouncement"
|
|
:has-changes="hasAnnouncementChanges"
|
|
@begin="beginCustomizeAnnouncement"
|
|
@cancel="cancelCustomizeAnnouncement"
|
|
@save="saveAnnouncementSection"
|
|
/>
|
|
|
|
<AdminSiteCodeSettingsCard
|
|
:form="form"
|
|
:editing="editSiteCode"
|
|
:saving="savingSiteCode"
|
|
:has-changes="hasSiteCodeChanges"
|
|
@begin="beginEditSiteCode"
|
|
@cancel="cancelEditSiteCode"
|
|
@save="saveSiteCodeSection"
|
|
/>
|
|
|
|
<AdminAdsSettingsCard
|
|
:form="form"
|
|
:editing="editAds"
|
|
:saving="savingAds"
|
|
:has-changes="hasAdsChanges"
|
|
@begin="beginEditAds"
|
|
@cancel="cancelEditAds"
|
|
@save="saveAdsSection"
|
|
/>
|
|
|
|
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
|
|
콘텐츠·안전
|
|
</h2>
|
|
<AdminBackupSummaryCard
|
|
:summary="backupSummary"
|
|
:pending="backupSummaryPending"
|
|
@refresh="refreshBackupSummary"
|
|
/>
|
|
|
|
<AdminPostExportRequestCard
|
|
v-model:date-range-mode="postExportDateRangeMode"
|
|
v-model:year="postExportYear"
|
|
v-model:month="postExportMonth"
|
|
v-model:date-from="postExportDateFrom"
|
|
v-model:date-to="postExportDateTo"
|
|
v-model:max-file-size-mb="postExportMaxFileSizeMb"
|
|
v-model:chunk-size="postExportChunkSize"
|
|
:expanded="postImportExportPanel === 'export'"
|
|
:date-range-mode-options="postExportDateRangeModeOptions"
|
|
:year-options="postExportYearSelectOptions"
|
|
:month-options="postExportMonthSelectOptions"
|
|
:can-request="canRequestPostExport"
|
|
:requesting="requestingPostExport"
|
|
:has-active-jobs="hasActivePostExportJobs"
|
|
:request-title="postExportRequestTitle"
|
|
@toggle="togglePostImportExportPanel('export')"
|
|
@request="requestPostExport"
|
|
/>
|
|
|
|
<AdminPostExportJobList
|
|
:jobs="normalizedPostExportJobs"
|
|
:format-date-time="formatPostDateTime"
|
|
:get-status-label="getPostExportStatusLabel"
|
|
:get-status-class="getPostExportStatusClass"
|
|
:should-show-progress="shouldShowPostExportProgress"
|
|
:get-progress-label="getPostExportProgressLabel"
|
|
:get-progress-percent="getPostExportProgressPercent"
|
|
:get-progress-description="getPostExportProgressDescription"
|
|
:is-retrying="isRetryingPostExportJob"
|
|
:is-deleting="isDeletingPostExportJob"
|
|
:is-downloading="isDownloadingPostExportJob"
|
|
:are-all-ready-files-selected="areAllReadyPostExportFilesSelected"
|
|
:get-ready-files="getReadyPostExportFiles"
|
|
:get-selected-ready-files="getSelectedReadyPostExportFiles"
|
|
:is-file-selected="isPostExportFileSelected"
|
|
:is-file-selection-disabled="isPostExportFileSelectionDisabled"
|
|
@refresh="refreshPostExportJobs"
|
|
@retry="retryPostExportJob"
|
|
@delete="deletePostExportJob"
|
|
@toggle-all-files="toggleAllPostExportFiles"
|
|
@download-files="downloadPostExportJobFiles"
|
|
@toggle-file="togglePostExportFileSelection"
|
|
/>
|
|
|
|
<AdminPostImportCard
|
|
:expanded="postImportExportPanel === 'import'"
|
|
:importing="importingPosts"
|
|
:file-name="postImportFileName"
|
|
:has-file="Boolean(postImportFile)"
|
|
:has-jobs="normalizedPostImportJobs.length > 0"
|
|
@toggle="togglePostImportExportPanel('import')"
|
|
@select-file="selectPostImportFile"
|
|
@submit="importSelectedPostFile"
|
|
@open-jobs="openPostImportJobModal()"
|
|
/>
|
|
|
|
<AdminSpamSettingsCard
|
|
v-model:blocked-usernames-text="signupBlockedUsernamesText"
|
|
:blocked-usernames="form.signupBlockedUsernames"
|
|
:editing="editSpam"
|
|
:saving="savingSpam"
|
|
:has-changes="hasSpamChanges"
|
|
@begin="beginEditSpam"
|
|
@cancel="cancelEditSpam"
|
|
@save="saveSpamSection"
|
|
/>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<AdminPostImportJobModal
|
|
:open="isPostImportJobModalOpen"
|
|
:job="selectedPostImportJob"
|
|
:deleting="selectedPostImportJob ? isDeletingPostImportJob(selectedPostImportJob.id) : false"
|
|
@close="closePostImportJobModal"
|
|
@refresh="refreshPostImportJobs"
|
|
@delete="deletePostImportJob"
|
|
/>
|
|
|
|
<AdminToast :toast="toast" top-class="top-24 md:top-28" />
|
|
</div>
|
|
</template>
|