Files
sori.studio/pages/admin/settings/index.vue

2082 lines
67 KiB
Vue

<script setup>
import {
DEFAULT_ANNOUNCEMENT_ALIGNMENT,
DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR,
normalizeAnnouncementAlignment,
normalizeAnnouncementBackgroundColor
} from '~/lib/announcement-bar.js'
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from '~/lib/brand-color.js'
import {
normalizeSignupBlockedUsernames,
parseSignupBlockedUsernamesFromText
} from '~/lib/signup-blocked-usernames.js'
import {
normalizeSocialLinks
} from '~/lib/social-links.js'
import {
DEFAULT_POST_TAG_LIMIT,
MAX_POST_TAG_LIMIT,
MIN_POST_TAG_LIMIT,
normalizePostTagLimit
} from '~/lib/post-tag-limit.js'
import {
DEFAULT_FEATURED_POST_LIMIT,
DEFAULT_HOME_LATEST_POST_LIMIT,
DEFAULT_PAGE_SECTION_ITEM_LIMIT,
DEFAULT_POST_LIST_PAGE_SIZE,
DEFAULT_TAG_POST_LIST_PAGE_SIZE,
DISPLAY_COUNT_OPTIONS,
POST_LIST_COUNT_OPTIONS,
normalizeDisplayCount
} from '~/lib/display-count-settings.js'
import {
DEFAULT_UPLOAD_MAX_FILE_COUNT,
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
UPLOAD_FILE_COUNT_OPTIONS,
UPLOAD_TOTAL_SIZE_OPTIONS,
formatUploadSizeLimit,
normalizeUploadMaxFileCount,
normalizeUploadMaxTotalSize
} from '~/lib/upload-size-limit.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({
socialLinks: []
})
/** 편집 시작 시점의 POST 설정(취소 시 복원용) */
const postSnapshot = reactive({
showPostUpdatedAt: false,
postTagLimit: DEFAULT_POST_TAG_LIMIT,
homeLatestPostLimit: DEFAULT_HOME_LATEST_POST_LIMIT,
postListPageSize: DEFAULT_POST_LIST_PAGE_SIZE,
tagPostListPageSize: DEFAULT_TAG_POST_LIST_PAGE_SIZE,
featuredPostLimit: DEFAULT_FEATURED_POST_LIMIT,
pageSectionItemLimit: DEFAULT_PAGE_SECTION_ITEM_LIMIT,
uploadMaxFileCount: DEFAULT_UPLOAD_MAX_FILE_COUNT,
uploadMaxTotalSize: DEFAULT_UPLOAD_MAX_TOTAL_SIZE
})
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
const homeCoverSnapshot = reactive({
homeCoverImageUrl: '',
homeCoverDarkImageUrl: '',
homeCoverTitle: '',
homeCoverText: ''
})
/** 편집 시작 시점의 브랜드 설정(취소 시 복원용) */
const brandSnapshot = reactive({
brandColor: DEFAULT_BRAND_COLOR
})
/** 맞춤 설정 시작 시점의 어나운스 바(취소 시 복원용) */
const announcementSnapshot = reactive({
announcementEnabled: false,
announcementText: '',
announcementUrl: '',
announcementBackgroundColor: DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR,
announcementAlignment: DEFAULT_ANNOUNCEMENT_ALIGNMENT
})
/** 편집 시작 시점의 스팸 필터(취소 시 복원용) */
const spamSnapshot = reactive({
signupBlockedUsernames: []
})
/** 편집 시작 시점의 사이트 코드(취소 시 복원용) */
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 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',
socialLinks: normalizeSocialLinks(settings.value?.socialLinks || []),
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt),
postTagLimit: normalizePostTagLimit(settings.value?.postTagLimit),
homeLatestPostLimit: normalizeDisplayCount(settings.value?.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT),
postListPageSize: normalizeDisplayCount(settings.value?.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE),
tagPostListPageSize: normalizeDisplayCount(settings.value?.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
featuredPostLimit: normalizeDisplayCount(settings.value?.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
pageSectionItemLimit: normalizeDisplayCount(settings.value?.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
uploadMaxFileCount: normalizeUploadMaxFileCount(settings.value?.uploadMaxFileCount),
uploadMaxTotalSize: normalizeUploadMaxTotalSize(settings.value?.uploadMaxTotalSize),
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
homeCoverTitle: settings.value?.homeCoverTitle || '',
homeCoverText: settings.value?.homeCoverText || '',
brandColor: normalizeBrandColor(settings.value?.brandColor || DEFAULT_BRAND_COLOR),
announcementEnabled: Boolean(settings.value?.announcementEnabled),
announcementText: settings.value?.announcementText || '',
announcementUrl: settings.value?.announcementUrl || '',
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(settings.value?.announcementBackgroundColor || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR),
announcementAlignment: normalizeAnnouncementAlignment(settings.value?.announcementAlignment || DEFAULT_ANNOUNCEMENT_ALIGNMENT),
signupBlockedUsernames: normalizeSignupBlockedUsernames(settings.value?.signupBlockedUsernames),
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
&& JSON.stringify(normalizeSocialLinks(form.socialLinks)) !== JSON.stringify(normalizeSocialLinks(socialSnapshot.socialLinks)))
/**
* POST 설정 변경 여부
* @returns {boolean} 변경 여부
*/
const hasPostChanges = computed(() => editPost.value
&& (
form.showPostUpdatedAt !== postSnapshot.showPostUpdatedAt
|| normalizePostTagLimit(form.postTagLimit) !== normalizePostTagLimit(postSnapshot.postTagLimit)
|| normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT) !== normalizeDisplayCount(postSnapshot.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
|| normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE) !== normalizeDisplayCount(postSnapshot.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
|| normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE) !== normalizeDisplayCount(postSnapshot.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
|| normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT) !== normalizeDisplayCount(postSnapshot.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
|| normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT) !== normalizeDisplayCount(postSnapshot.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
|| normalizeUploadMaxFileCount(form.uploadMaxFileCount) !== normalizeUploadMaxFileCount(postSnapshot.uploadMaxFileCount)
|| normalizeUploadMaxTotalSize(form.uploadMaxTotalSize) !== normalizeUploadMaxTotalSize(postSnapshot.uploadMaxTotalSize)
))
/**
* 메인 화면 커버 변경 여부
* @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
&& normalizeBrandColor(form.brandColor) !== normalizeBrandColor(brandSnapshot.brandColor))
/**
* 어나운스 바 변경 여부
* @returns {boolean} 변경 여부
*/
const hasAnnouncementChanges = computed(() => customizeAnnouncement.value && (
form.announcementEnabled !== announcementSnapshot.announcementEnabled
|| form.announcementText !== announcementSnapshot.announcementText
|| form.announcementUrl !== announcementSnapshot.announcementUrl
|| normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor) !== normalizeAnnouncementBackgroundColor(announcementSnapshot.announcementBackgroundColor)
|| normalizeAnnouncementAlignment(form.announcementAlignment) !== normalizeAnnouncementAlignment(announcementSnapshot.announcementAlignment)
))
/**
* 스팸 필터 변경 여부
* @returns {boolean} 변경 여부
*/
const hasSpamChanges = computed(() => editSpam.value
&& JSON.stringify(form.signupBlockedUsernames) !== JSON.stringify(spamSnapshot.signupBlockedUsernames))
/**
* 사이트 코드 변경 여부
* @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: () => form.signupBlockedUsernames.join('\n'),
set: (value) => {
form.signupBlockedUsernames = parseSignupBlockedUsernamesFromText(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,
socialLinks: normalizeSocialLinks(form.socialLinks),
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
postTagLimit: normalizePostTagLimit(form.postTagLimit),
homeLatestPostLimit: normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT),
postListPageSize: normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE),
tagPostListPageSize: normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
featuredPostLimit: normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
pageSectionItemLimit: normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
uploadMaxFileCount: normalizeUploadMaxFileCount(form.uploadMaxFileCount),
uploadMaxTotalSize: normalizeUploadMaxTotalSize(form.uploadMaxTotalSize),
homeCoverImageUrl: form.homeCoverImageUrl || '',
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
homeCoverTitle: form.homeCoverTitle || '',
homeCoverText: form.homeCoverText || '',
brandColor: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR),
announcementEnabled: Boolean(form.announcementEnabled),
announcementText: form.announcementText || '',
announcementUrl: form.announcementUrl || '',
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR),
announcementAlignment: normalizeAnnouncementAlignment(form.announcementAlignment || DEFAULT_ANNOUNCEMENT_ALIGNMENT),
signupBlockedUsernames: normalizeSignupBlockedUsernames(form.signupBlockedUsernames),
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 = () => {
socialSnapshot.socialLinks = normalizeSocialLinks(form.socialLinks)
form.socialLinks = normalizeSocialLinks(form.socialLinks)
editSocial.value = true
}
/**
* SNS 정보 편집 취소
* @returns {void}
*/
const cancelEditSocial = () => {
form.socialLinks = normalizeSocialLinks(socialSnapshot.socialLinks)
editSocial.value = false
}
/**
* SNS 정보를 저장한다.
* @returns {Promise<void>}
*/
const saveSocialSection = async () => {
if (!hasSocialChanges.value) {
return
}
form.socialLinks = normalizeSocialLinks(form.socialLinks)
const ok = await persistSiteSettings({
successToast: 'SNS 정보가 저장되었습니다.',
savingFlag: savingSocial,
fields: ['socialLinks']
})
if (ok) {
socialSnapshot.socialLinks = normalizeSocialLinks(form.socialLinks)
editSocial.value = false
}
}
/**
* POST 설정 편집 모드 진입
* @returns {void}
*/
const beginEditPost = () => {
postSnapshot.showPostUpdatedAt = form.showPostUpdatedAt
postSnapshot.postTagLimit = normalizePostTagLimit(form.postTagLimit)
postSnapshot.homeLatestPostLimit = normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
postSnapshot.postListPageSize = normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
postSnapshot.tagPostListPageSize = normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
postSnapshot.featuredPostLimit = normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
postSnapshot.pageSectionItemLimit = normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
postSnapshot.uploadMaxFileCount = normalizeUploadMaxFileCount(form.uploadMaxFileCount)
postSnapshot.uploadMaxTotalSize = normalizeUploadMaxTotalSize(form.uploadMaxTotalSize)
form.postTagLimit = normalizePostTagLimit(form.postTagLimit)
form.homeLatestPostLimit = postSnapshot.homeLatestPostLimit
form.postListPageSize = postSnapshot.postListPageSize
form.tagPostListPageSize = postSnapshot.tagPostListPageSize
form.featuredPostLimit = postSnapshot.featuredPostLimit
form.pageSectionItemLimit = postSnapshot.pageSectionItemLimit
form.uploadMaxFileCount = postSnapshot.uploadMaxFileCount
form.uploadMaxTotalSize = postSnapshot.uploadMaxTotalSize
editPost.value = true
}
/**
* POST 설정 편집 취소
* @returns {void}
*/
const cancelEditPost = () => {
form.showPostUpdatedAt = postSnapshot.showPostUpdatedAt
form.postTagLimit = normalizePostTagLimit(postSnapshot.postTagLimit)
form.homeLatestPostLimit = normalizeDisplayCount(postSnapshot.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
form.postListPageSize = normalizeDisplayCount(postSnapshot.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
form.tagPostListPageSize = normalizeDisplayCount(postSnapshot.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
form.featuredPostLimit = normalizeDisplayCount(postSnapshot.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
form.pageSectionItemLimit = normalizeDisplayCount(postSnapshot.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
form.uploadMaxFileCount = normalizeUploadMaxFileCount(postSnapshot.uploadMaxFileCount)
form.uploadMaxTotalSize = normalizeUploadMaxTotalSize(postSnapshot.uploadMaxTotalSize)
editPost.value = false
}
/**
* POST 설정 저장
* @returns {Promise<void>}
*/
const savePostSection = async () => {
if (!hasPostChanges.value) {
return
}
const ok = await persistSiteSettings({
successToast: 'POST 설정이 저장되었습니다.',
savingFlag: savingPost,
fields: [
'showPostUpdatedAt',
'postTagLimit',
'homeLatestPostLimit',
'postListPageSize',
'tagPostListPageSize',
'featuredPostLimit',
'pageSectionItemLimit',
'uploadMaxFileCount',
'uploadMaxTotalSize'
]
})
if (ok) {
postSnapshot.showPostUpdatedAt = form.showPostUpdatedAt
postSnapshot.postTagLimit = normalizePostTagLimit(form.postTagLimit)
postSnapshot.homeLatestPostLimit = normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
postSnapshot.postListPageSize = normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
postSnapshot.tagPostListPageSize = normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
postSnapshot.featuredPostLimit = normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
postSnapshot.pageSectionItemLimit = normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
postSnapshot.uploadMaxFileCount = normalizeUploadMaxFileCount(form.uploadMaxFileCount)
postSnapshot.uploadMaxTotalSize = normalizeUploadMaxTotalSize(form.uploadMaxTotalSize)
form.postTagLimit = postSnapshot.postTagLimit
form.homeLatestPostLimit = postSnapshot.homeLatestPostLimit
form.postListPageSize = postSnapshot.postListPageSize
form.tagPostListPageSize = postSnapshot.tagPostListPageSize
form.featuredPostLimit = postSnapshot.featuredPostLimit
form.pageSectionItemLimit = postSnapshot.pageSectionItemLimit
form.uploadMaxFileCount = postSnapshot.uploadMaxFileCount
form.uploadMaxTotalSize = postSnapshot.uploadMaxTotalSize
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 = () => {
brandSnapshot.brandColor = normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR)
form.brandColor = brandSnapshot.brandColor
editBrand.value = true
}
/**
* 브랜드 설정 편집 취소
* @returns {void}
*/
const cancelEditBrand = () => {
form.brandColor = brandSnapshot.brandColor
editBrand.value = false
}
/**
* 브랜드 컬러 입력값을 정규화한다.
* @returns {void}
*/
const normalizeBrandColorInput = () => {
form.brandColor = normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR)
}
/**
* 브랜드 설정 저장
* @returns {Promise<void>}
*/
const saveBrandSection = async () => {
normalizeBrandColorInput()
if (!hasBrandChanges.value) {
return
}
const ok = await persistSiteSettings({
successToast: '브랜드 컬러가 저장되었습니다.',
savingFlag: savingBrand,
fields: ['brandColor']
})
if (ok) {
brandSnapshot.brandColor = normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR)
editBrand.value = false
}
}
/**
* 어나운스 바 맞춤 설정 패널을 연다.
* @returns {void}
*/
const beginCustomizeAnnouncement = () => {
announcementSnapshot.announcementEnabled = form.announcementEnabled
announcementSnapshot.announcementText = form.announcementText
announcementSnapshot.announcementUrl = form.announcementUrl
announcementSnapshot.announcementBackgroundColor = normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR)
announcementSnapshot.announcementAlignment = normalizeAnnouncementAlignment(form.announcementAlignment || DEFAULT_ANNOUNCEMENT_ALIGNMENT)
customizeAnnouncement.value = true
}
/**
* 어나운스 바 맞춤 설정을 취소한다.
* @returns {void}
*/
const cancelCustomizeAnnouncement = () => {
form.announcementEnabled = announcementSnapshot.announcementEnabled
form.announcementText = announcementSnapshot.announcementText
form.announcementUrl = announcementSnapshot.announcementUrl
form.announcementBackgroundColor = announcementSnapshot.announcementBackgroundColor
form.announcementAlignment = announcementSnapshot.announcementAlignment
customizeAnnouncement.value = false
}
/**
* 어나운스 바 설정을 저장한다.
* @returns {Promise<void>}
*/
const saveAnnouncementSection = async () => {
if (!hasAnnouncementChanges.value) {
return
}
const ok = await persistSiteSettings({
successToast: '어나운스 바 설정이 저장되었습니다.',
savingFlag: savingAnnouncement,
fields: [
'announcementEnabled',
'announcementText',
'announcementUrl',
'announcementBackgroundColor',
'announcementAlignment'
]
})
if (ok) {
announcementSnapshot.announcementEnabled = form.announcementEnabled
announcementSnapshot.announcementText = form.announcementText
announcementSnapshot.announcementUrl = form.announcementUrl
announcementSnapshot.announcementBackgroundColor = normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR)
announcementSnapshot.announcementAlignment = normalizeAnnouncementAlignment(form.announcementAlignment || DEFAULT_ANNOUNCEMENT_ALIGNMENT)
customizeAnnouncement.value = false
}
}
/**
* 스팸 필터 편집 모드 진입
* @returns {void}
*/
const beginEditSpam = () => {
spamSnapshot.signupBlockedUsernames = [...form.signupBlockedUsernames]
editSpam.value = true
}
/**
* 스팸 필터 편집 취소
* @returns {void}
*/
const cancelEditSpam = () => {
form.signupBlockedUsernames = [...spamSnapshot.signupBlockedUsernames]
editSpam.value = false
}
/**
* 스팸 필터 저장
* @returns {Promise<void>}
*/
const saveSpamSection = async () => {
if (!hasSpamChanges.value) {
return
}
const ok = await persistSiteSettings({
successToast: '스팸 필터 설정이 저장되었습니다.',
savingFlag: savingSpam,
fields: ['signupBlockedUsernames']
})
if (ok) {
spamSnapshot.signupBlockedUsernames = [...form.signupBlockedUsernames]
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"
:upload-file-count-options="uploadFileCountSelectOptions"
:upload-total-size-options="uploadTotalSizeSelectOptions"
@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>