1258 lines
32 KiB
JavaScript
1258 lines
32 KiB
JavaScript
import {
|
|
getSamplePageBySlug,
|
|
getSamplePages,
|
|
getSamplePostBySlug,
|
|
getSamplePosts,
|
|
getSampleTags
|
|
} from '../utils/sample-content'
|
|
import { getDefaultNavigationItems } from '../utils/navigation-items'
|
|
import { buildPublicPrimaryTree, orderNavigationItemsForInsert } from '../utils/navigation-tree'
|
|
import { getDefaultSiteSettings } from '../utils/site-settings'
|
|
import { toAdminPostFormTitle } from '../../lib/admin-post-title.js'
|
|
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,
|
|
parseSignupBlockedUsernamesFromDb
|
|
} from '../../lib/signup-blocked-usernames.js'
|
|
import { getPostgresClient } from './postgres-client'
|
|
|
|
/**
|
|
* 게시물 행을 API 응답 구조로 변환
|
|
* @param {Object} row - 게시물 행
|
|
* @returns {Object} 게시물 응답
|
|
*/
|
|
const mapPostRow = (row) => ({
|
|
id: row.id,
|
|
title: row.title,
|
|
slug: row.slug,
|
|
authorId: row.author_id || null,
|
|
content: row.content,
|
|
excerpt: row.excerpt,
|
|
featuredImage: row.featured_image,
|
|
isFeatured: Boolean(row.is_featured),
|
|
commentCount: Number(row.comment_count || 0),
|
|
seoTitle: row.seo_title || '',
|
|
seoDescription: row.seo_description || '',
|
|
canonicalUrl: row.canonical_url || '',
|
|
noindex: Boolean(row.noindex),
|
|
ogImage: row.og_image || null,
|
|
status: row.status,
|
|
publishedAt: row.published_at ? row.published_at.toISOString() : null,
|
|
createdAt: row.created_at.toISOString(),
|
|
updatedAt: row.updated_at.toISOString(),
|
|
tags: row.tags || []
|
|
})
|
|
|
|
/**
|
|
* 관리자 API용 게시물 행 변환(제목 없음 플레이스홀더는 빈 문자열)
|
|
* @param {Object} row - 게시물 행
|
|
* @returns {Object} 게시물 응답
|
|
*/
|
|
const mapAdminPostRow = (row) => ({
|
|
...mapPostRow(row),
|
|
title: toAdminPostFormTitle(row.title)
|
|
})
|
|
|
|
/**
|
|
* 고정 페이지 행을 API 응답 구조로 변환
|
|
* @param {Object} row - 고정 페이지 행
|
|
* @returns {Object} 고정 페이지 응답
|
|
*/
|
|
const mapPageRow = (row) => ({
|
|
id: row.id,
|
|
title: row.title,
|
|
slug: row.slug,
|
|
content: row.content,
|
|
renderMode: row.render_mode || 'markdown',
|
|
status: row.status || 'published',
|
|
featuredImage: row.featured_image,
|
|
createdAt: row.created_at.toISOString(),
|
|
updatedAt: row.updated_at.toISOString()
|
|
})
|
|
|
|
/**
|
|
* 태그 행을 API 응답 구조로 변환
|
|
* @param {Object} row - 태그 행
|
|
* @returns {Object} 태그 응답
|
|
*/
|
|
const mapTagRow = (row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
slug: row.slug,
|
|
description: row.description,
|
|
sortOrder: row.sort_order,
|
|
color: row.color,
|
|
tagType: row.tag_type || 'managed',
|
|
postCount: Number(row.post_count || 0),
|
|
lastUsedAt: row.last_used_at ? row.last_used_at.toISOString() : null,
|
|
updatedAt: row.updated_at ? row.updated_at.toISOString() : null
|
|
})
|
|
|
|
/**
|
|
* 사이트 설정 행을 API 응답 구조로 변환
|
|
* @param {Object} row - 사이트 설정 행
|
|
* @returns {Object} 사이트 설정 응답
|
|
*/
|
|
const mapSiteSettingsRow = (row) => ({
|
|
title: row.title,
|
|
description: row.description,
|
|
siteUrl: row.site_url,
|
|
logoText: row.logo_text,
|
|
logoUrl: row.logo_url || '',
|
|
faviconUrl: row.favicon_url || '',
|
|
copyrightText: row.copyright_text,
|
|
showPostUpdatedAt: Boolean(row.show_post_updated_at),
|
|
homeCoverImageUrl: row.home_cover_image_url || '',
|
|
homeCoverDarkImageUrl: row.home_cover_dark_image_url || '',
|
|
homeCoverTitle: row.home_cover_title || '',
|
|
homeCoverText: row.home_cover_text || '',
|
|
brandColor: normalizeBrandColor(row.brand_color || DEFAULT_BRAND_COLOR),
|
|
announcementEnabled: Boolean(row.announcement_enabled),
|
|
announcementText: row.announcement_text || '',
|
|
announcementUrl: row.announcement_url || '',
|
|
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(row.announcement_background_color || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR),
|
|
announcementAlignment: normalizeAnnouncementAlignment(row.announcement_alignment || DEFAULT_ANNOUNCEMENT_ALIGNMENT),
|
|
signupBlockedUsernames: parseSignupBlockedUsernamesFromDb(row.signup_blocked_usernames),
|
|
adsTxt: row.ads_txt || '',
|
|
customHeadCode: row.custom_head_code || '',
|
|
customFooterCode: row.custom_footer_code || '',
|
|
updatedAt: row.updated_at.toISOString()
|
|
})
|
|
|
|
/**
|
|
* 네비게이션 행을 API 응답 구조로 변환
|
|
* @param {Object} row - 네비게이션 행
|
|
* @returns {Object} 네비게이션 응답
|
|
*/
|
|
const mapNavigationItemRow = (row) => ({
|
|
id: row.id,
|
|
label: row.label,
|
|
url: row.url,
|
|
descriptionText: row.description_text || '',
|
|
thumbnailUrl: row.thumbnail_url || '',
|
|
location: row.location,
|
|
sortOrder: row.sort_order,
|
|
isVisible: row.is_visible,
|
|
parentId: row.parent_id ?? null,
|
|
isFolder: Boolean(row.is_folder),
|
|
createdAt: row.created_at.toISOString(),
|
|
updatedAt: row.updated_at.toISOString()
|
|
})
|
|
|
|
/**
|
|
* 태그 슬러그 목록 정규화
|
|
* @param {Array<string>} tags - 태그 슬러그 목록
|
|
* @returns {Array<string>} 정규화된 태그 슬러그 목록
|
|
*/
|
|
const normalizeTagSlugs = (tags = []) => {
|
|
const seen = new Set()
|
|
const out = []
|
|
|
|
for (const tag of tags) {
|
|
const trimmed = String(tag).trim().normalize('NFC')
|
|
if (!trimmed) {
|
|
continue
|
|
}
|
|
const dedupeKey = trimmed.toLowerCase()
|
|
if (seen.has(dedupeKey)) {
|
|
continue
|
|
}
|
|
seen.add(dedupeKey)
|
|
out.push(trimmed)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* 태그 슬러그를 표시용 태그명으로 변환
|
|
* @param {string} slug - 태그 슬러그
|
|
* @returns {string} 태그명
|
|
*/
|
|
const getTagNameFromSlug = (slug) => {
|
|
const trimmed = String(slug).trim()
|
|
if (!trimmed) {
|
|
return ''
|
|
}
|
|
if (/[가-힣]/.test(trimmed)) {
|
|
return trimmed.split('-').filter(Boolean).join(' ')
|
|
}
|
|
|
|
return trimmed
|
|
.split('-')
|
|
.filter(Boolean)
|
|
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
|
|
.join(' ')
|
|
}
|
|
|
|
/**
|
|
* 게시물 태그 연결 저장
|
|
* @param {Function} sql - PostgreSQL 트랜잭션 클라이언트
|
|
* @param {string} postId - 게시물 ID
|
|
* @param {Array<string>} tags - 태그 슬러그 목록
|
|
* @returns {Promise<void>} 저장 결과
|
|
*/
|
|
const syncPostTags = async (sql, postId, tags) => {
|
|
const tagSlugs = normalizeTagSlugs(tags)
|
|
|
|
await sql`
|
|
DELETE FROM post_tags
|
|
WHERE post_id = ${postId}
|
|
`
|
|
|
|
for (const slug of tagSlugs) {
|
|
const tagRows = await sql`
|
|
INSERT INTO tags (name, slug, tag_type, sort_order)
|
|
VALUES (${getTagNameFromSlug(slug)}, ${slug}, 'general', 0)
|
|
ON CONFLICT (slug) DO UPDATE
|
|
SET updated_at = now()
|
|
RETURNING id
|
|
`
|
|
|
|
await sql`
|
|
INSERT INTO post_tags (post_id, tag_id)
|
|
VALUES (${postId}, ${tagRows[0].id})
|
|
ON CONFLICT DO NOTHING
|
|
`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 공개 게시물 목록 조회
|
|
* @param {{ includeMembership?: boolean }} [options] - VIP 전용 글 포함 여부
|
|
* @returns {Promise<Array>} 게시물 목록
|
|
*/
|
|
export const listPosts = async ({ includeMembership = false } = {}) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getSamplePosts()
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT
|
|
posts.*,
|
|
(
|
|
SELECT COUNT(*)::int
|
|
FROM comments
|
|
WHERE comments.post_id = posts.id
|
|
AND comments.status = 'published'
|
|
) AS comment_count,
|
|
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
|
FROM posts
|
|
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
|
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
|
WHERE (
|
|
posts.status = 'published'
|
|
OR (${includeMembership} = true AND posts.status = 'members')
|
|
)
|
|
AND (
|
|
posts.status = 'members'
|
|
OR posts.published_at IS NULL
|
|
OR posts.published_at <= now()
|
|
)
|
|
GROUP BY posts.id
|
|
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
|
|
`
|
|
|
|
return rows.map(mapPostRow)
|
|
}
|
|
|
|
/**
|
|
* 관리자 게시물 목록 조회
|
|
* @returns {Promise<Array>} 관리자 게시물 목록
|
|
*/
|
|
export const listAdminPosts = async () => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getSamplePosts()
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT
|
|
posts.*,
|
|
(
|
|
SELECT COUNT(*)::int
|
|
FROM comments
|
|
WHERE comments.post_id = posts.id
|
|
AND comments.status = 'published'
|
|
) AS comment_count,
|
|
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
|
FROM posts
|
|
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
|
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
|
GROUP BY posts.id
|
|
ORDER BY COALESCE(posts.published_at, posts.updated_at) DESC
|
|
`
|
|
|
|
return rows.map(mapAdminPostRow)
|
|
}
|
|
|
|
/**
|
|
* 관리자 게시물 상세 조회
|
|
* @param {string} id - 게시물 ID
|
|
* @returns {Promise<Object | null>} 관리자 게시물 상세
|
|
*/
|
|
export const getAdminPostById = async (id) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getSamplePosts().find((post) => post.id === id) || null
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT
|
|
posts.*,
|
|
(
|
|
SELECT COUNT(*)::int
|
|
FROM comments
|
|
WHERE comments.post_id = posts.id
|
|
AND comments.status = 'published'
|
|
) AS comment_count,
|
|
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
|
FROM posts
|
|
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
|
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
|
WHERE posts.id = ${id}
|
|
GROUP BY posts.id
|
|
LIMIT 1
|
|
`
|
|
|
|
return rows[0] ? mapAdminPostRow(rows[0]) : null
|
|
}
|
|
|
|
/**
|
|
* 관리자 게시물 생성
|
|
* @param {Object} input - 게시물 입력값
|
|
* @param {string} authorId - 작성자 회원 ID
|
|
* @returns {Promise<Object>} 생성된 게시물
|
|
*/
|
|
export const createAdminPost = async (input, authorId) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql.begin(async (transaction) => {
|
|
const insertedRows = await transaction`
|
|
INSERT INTO posts (
|
|
title,
|
|
slug,
|
|
author_id,
|
|
content,
|
|
excerpt,
|
|
featured_image,
|
|
is_featured,
|
|
seo_title,
|
|
seo_description,
|
|
canonical_url,
|
|
noindex,
|
|
og_image,
|
|
status,
|
|
published_at
|
|
)
|
|
VALUES (
|
|
${input.title},
|
|
${input.slug},
|
|
${authorId || null},
|
|
${input.content},
|
|
${input.excerpt},
|
|
${input.featuredImage},
|
|
${input.isFeatured},
|
|
${input.seoTitle},
|
|
${input.seoDescription},
|
|
${input.canonicalUrl},
|
|
${input.noindex},
|
|
${input.ogImage},
|
|
${input.status},
|
|
${input.publishedAt}
|
|
)
|
|
RETURNING *
|
|
`
|
|
|
|
await syncPostTags(transaction, insertedRows[0].id, input.tags)
|
|
|
|
return insertedRows
|
|
})
|
|
|
|
return getAdminPostById(rows[0].id)
|
|
}
|
|
|
|
/**
|
|
* 관리자 게시물 수정
|
|
* @param {string} id - 게시물 ID
|
|
* @param {Object} input - 게시물 입력값
|
|
* @param {string} editorId - 수정자 회원 ID
|
|
* @returns {Promise<Object | null>} 수정된 게시물
|
|
*/
|
|
export const updateAdminPost = async (id, input, editorId) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql.begin(async (transaction) => {
|
|
const updatedRows = await transaction`
|
|
UPDATE posts
|
|
SET
|
|
title = ${input.title},
|
|
slug = ${input.slug},
|
|
author_id = COALESCE(author_id, ${editorId || null}),
|
|
content = ${input.content},
|
|
excerpt = ${input.excerpt},
|
|
featured_image = ${input.featuredImage},
|
|
is_featured = ${input.isFeatured},
|
|
seo_title = ${input.seoTitle},
|
|
seo_description = ${input.seoDescription},
|
|
canonical_url = ${input.canonicalUrl},
|
|
noindex = ${input.noindex},
|
|
og_image = ${input.ogImage},
|
|
status = ${input.status},
|
|
published_at = ${input.publishedAt},
|
|
updated_at = now()
|
|
WHERE id = ${id}
|
|
RETURNING *
|
|
`
|
|
|
|
if (!updatedRows[0]) {
|
|
return []
|
|
}
|
|
|
|
await syncPostTags(transaction, id, input.tags)
|
|
|
|
return updatedRows
|
|
})
|
|
|
|
return rows[0] ? getAdminPostById(rows[0].id) : null
|
|
}
|
|
|
|
/**
|
|
* 관리자 게시물 삭제
|
|
* @param {string} id - 게시물 ID
|
|
* @returns {Promise<boolean>} 삭제 여부
|
|
*/
|
|
export const deleteAdminPost = async (id) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
DELETE FROM posts
|
|
WHERE id = ${id}
|
|
RETURNING id
|
|
`
|
|
|
|
return Boolean(rows[0])
|
|
}
|
|
|
|
/**
|
|
* 공개 게시물 상세 조회
|
|
* @param {string} slug - 게시물 슬러그
|
|
* @param {{ includeMembership?: boolean }} [options] - VIP 전용 글 포함 여부
|
|
* @returns {Promise<Object | null>} 게시물 상세
|
|
*/
|
|
export const getPostBySlug = async (slug, { includeMembership = false } = {}) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getSamplePostBySlug(slug)
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT
|
|
posts.*,
|
|
(
|
|
SELECT COUNT(*)::int
|
|
FROM comments
|
|
WHERE comments.post_id = posts.id
|
|
AND comments.status = 'published'
|
|
) AS comment_count,
|
|
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
|
FROM posts
|
|
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
|
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
|
WHERE posts.slug = ${slug}
|
|
AND (
|
|
posts.status = 'published'
|
|
OR (${includeMembership} = true AND posts.status = 'members')
|
|
)
|
|
AND (
|
|
posts.status = 'members'
|
|
OR posts.published_at IS NULL
|
|
OR posts.published_at <= now()
|
|
)
|
|
GROUP BY posts.id
|
|
LIMIT 1
|
|
`
|
|
|
|
return rows[0] ? mapPostRow(rows[0]) : null
|
|
}
|
|
|
|
/**
|
|
* 공개 고정 페이지 목록 조회
|
|
* @returns {Promise<Array>} 고정 페이지 목록
|
|
*/
|
|
export const listPages = async () => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getSamplePages()
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT *
|
|
FROM pages
|
|
WHERE status = 'published'
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
return rows.map(mapPageRow)
|
|
}
|
|
|
|
/**
|
|
* 관리자 고정 페이지 목록 조회
|
|
* @returns {Promise<Array>} 관리자 고정 페이지 목록
|
|
*/
|
|
export const listAdminPages = async () => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getSamplePages()
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT *
|
|
FROM pages
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
return rows.map(mapPageRow)
|
|
}
|
|
|
|
/**
|
|
* 관리자 고정 페이지 상세 조회
|
|
* @param {string} id - 페이지 ID
|
|
* @returns {Promise<Object | null>} 관리자 고정 페이지 상세
|
|
*/
|
|
export const getAdminPageById = async (id) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getSamplePages().find((page) => page.id === id) || null
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT *
|
|
FROM pages
|
|
WHERE id = ${id}
|
|
LIMIT 1
|
|
`
|
|
|
|
return rows[0] ? mapPageRow(rows[0]) : null
|
|
}
|
|
|
|
/**
|
|
* 관리자 고정 페이지 생성
|
|
* @param {Object} input - 페이지 입력값
|
|
* @returns {Promise<Object>} 생성된 페이지
|
|
*/
|
|
export const createAdminPage = async (input) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
INSERT INTO pages (
|
|
title,
|
|
slug,
|
|
content,
|
|
render_mode,
|
|
featured_image,
|
|
status
|
|
)
|
|
VALUES (
|
|
${input.title},
|
|
${input.slug},
|
|
${input.content},
|
|
${input.renderMode},
|
|
${input.featuredImage},
|
|
${input.status}
|
|
)
|
|
RETURNING *
|
|
`
|
|
|
|
return mapPageRow(rows[0])
|
|
}
|
|
|
|
/**
|
|
* 관리자 고정 페이지 수정
|
|
* @param {string} id - 페이지 ID
|
|
* @param {Object} input - 페이지 입력값
|
|
* @returns {Promise<Object | null>} 수정된 페이지
|
|
*/
|
|
export const updateAdminPage = async (id, input) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
UPDATE pages
|
|
SET
|
|
title = ${input.title},
|
|
slug = ${input.slug},
|
|
content = ${input.content},
|
|
render_mode = ${input.renderMode},
|
|
featured_image = ${input.featuredImage},
|
|
status = ${input.status},
|
|
updated_at = now()
|
|
WHERE id = ${id}
|
|
RETURNING *
|
|
`
|
|
|
|
return rows[0] ? mapPageRow(rows[0]) : null
|
|
}
|
|
|
|
/**
|
|
* 관리자 고정 페이지 삭제
|
|
* @param {string} id - 페이지 ID
|
|
* @returns {Promise<boolean>} 삭제 여부
|
|
*/
|
|
export const deleteAdminPage = async (id) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
DELETE FROM pages
|
|
WHERE id = ${id}
|
|
RETURNING id
|
|
`
|
|
|
|
return Boolean(rows[0])
|
|
}
|
|
|
|
/**
|
|
* 공개 고정 페이지 상세 조회
|
|
* @param {string} slug - 페이지 슬러그
|
|
* @returns {Promise<Object | null>} 고정 페이지 상세
|
|
*/
|
|
export const getPageBySlug = async (slug) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getSamplePageBySlug(slug)
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT *
|
|
FROM pages
|
|
WHERE slug = ${slug}
|
|
AND status = 'published'
|
|
LIMIT 1
|
|
`
|
|
|
|
return rows[0] ? mapPageRow(rows[0]) : null
|
|
}
|
|
|
|
/**
|
|
* 공개 태그 목록 조회
|
|
* @returns {Promise<Array>} 태그 목록
|
|
*/
|
|
export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
|
|
const sql = getPostgresClient()
|
|
const trimmedSearchQuery = String(searchQuery || '').trim().toLowerCase()
|
|
const resolvedLimit = Number.isInteger(limit) && limit > 0 ? limit : null
|
|
|
|
if (!sql) {
|
|
const sampleTags = getSampleTags().map((tag) => ({
|
|
...tag,
|
|
tagType: 'managed',
|
|
postCount: 0,
|
|
lastUsedAt: null,
|
|
updatedAt: null
|
|
}))
|
|
let filteredTags = sampleTags
|
|
if (tagType) {
|
|
filteredTags = filteredTags.filter((tag) => tag.tagType === tagType)
|
|
}
|
|
if (trimmedSearchQuery) {
|
|
filteredTags = filteredTags.filter((tag) =>
|
|
tag.name.toLowerCase().includes(trimmedSearchQuery) ||
|
|
tag.slug.toLowerCase().includes(trimmedSearchQuery)
|
|
)
|
|
}
|
|
return resolvedLimit ? filteredTags.slice(0, resolvedLimit) : filteredTags
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT
|
|
tags.*,
|
|
COUNT(post_tags.post_id)::int AS post_count,
|
|
MAX(posts.updated_at) AS last_used_at
|
|
FROM tags
|
|
LEFT JOIN post_tags ON post_tags.tag_id = tags.id
|
|
LEFT JOIN posts ON posts.id = post_tags.post_id
|
|
WHERE (${tagType || null}::text IS NULL OR tag_type = ${tagType || null})
|
|
AND (
|
|
${trimmedSearchQuery || null}::text IS NULL
|
|
OR strpos(lower(tags.name), ${trimmedSearchQuery || ''}) > 0
|
|
OR strpos(lower(tags.slug), ${trimmedSearchQuery || ''}) > 0
|
|
)
|
|
GROUP BY tags.id
|
|
ORDER BY
|
|
CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC,
|
|
sort_order ASC,
|
|
MAX(posts.updated_at) DESC NULLS LAST,
|
|
tags.updated_at DESC,
|
|
name ASC
|
|
LIMIT ${resolvedLimit || 1000}
|
|
`
|
|
|
|
return rows.map(mapTagRow)
|
|
}
|
|
|
|
/**
|
|
* 관리자 태그 목록 조회
|
|
* @param {Object} options - 조회 옵션
|
|
* @returns {Promise<Array>} 관리자 태그 목록
|
|
*/
|
|
export const listAdminTags = async (options = {}) => listTags(options)
|
|
|
|
const SEARCH_TAG_LIMIT = 12
|
|
const SEARCH_POST_LIMIT = 12
|
|
const SEARCH_POST_CANDIDATE_LIMIT = 48
|
|
|
|
/**
|
|
* 공개 검색에서 업로드 경로/파일명 같은 노이즈를 제거한다.
|
|
* - 마크다운 이미지 문법 `` 제거
|
|
* - 업로드 경로(`/uploads/...`) 제거
|
|
* @param {string} value - 원문
|
|
* @returns {string} 정규화된 문자열
|
|
*/
|
|
const normalizeSearchContent = (value) => String(value || '')
|
|
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
|
.replace(/\/uploads\/[^\s)"]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
|
|
/**
|
|
* 공개 검색: 태그·게시물 제목·요약·본문에서 부분 일치(대소문자 무시)
|
|
* @param {string} rawQuery - 검색어
|
|
* @returns {Promise<{ tags: Array<{ name: string, slug: string }>, posts: Array<{ slug: string, title: string, excerpt: string }> }>} 태그·게시물 요약 결과
|
|
*/
|
|
export const searchPublicContent = async (rawQuery) => {
|
|
const q = String(rawQuery || '').trim()
|
|
if (!q) {
|
|
return { tags: [], posts: [] }
|
|
}
|
|
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
const needle = q.toLowerCase()
|
|
const posts = getSamplePosts()
|
|
.filter((post) => {
|
|
const hay = normalizeSearchContent(`${post.title}\n${post.excerpt || ''}\n${post.content || ''}`).toLowerCase()
|
|
return hay.includes(needle)
|
|
})
|
|
.slice(0, SEARCH_POST_LIMIT)
|
|
.map((post) => ({
|
|
slug: post.slug,
|
|
title: post.title,
|
|
excerpt: post.excerpt || ''
|
|
}))
|
|
const tags = getSampleTags()
|
|
.filter((tag) => {
|
|
const hay = `${tag.name}\n${tag.slug}`.toLowerCase()
|
|
return hay.includes(needle)
|
|
})
|
|
.slice(0, SEARCH_TAG_LIMIT)
|
|
.map((tag) => ({
|
|
name: tag.name,
|
|
slug: tag.slug
|
|
}))
|
|
return { tags, posts }
|
|
}
|
|
|
|
const tagRows = await sql`
|
|
SELECT name, slug
|
|
FROM tags
|
|
WHERE
|
|
position(lower(${q}) in lower(name)) > 0
|
|
OR position(lower(${q}) in lower(slug)) > 0
|
|
ORDER BY sort_order ASC, name ASC
|
|
LIMIT ${SEARCH_TAG_LIMIT}
|
|
`
|
|
|
|
const postRows = await sql`
|
|
SELECT posts.slug, posts.title, posts.excerpt, posts.content
|
|
FROM posts
|
|
WHERE posts.status = 'published'
|
|
AND (posts.published_at IS NULL OR posts.published_at <= now())
|
|
AND (
|
|
position(lower(${q}) in lower(posts.title)) > 0
|
|
OR position(lower(${q}) in lower(coalesce(posts.excerpt, ''))) > 0
|
|
OR position(lower(${q}) in lower(posts.content)) > 0
|
|
)
|
|
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
|
|
LIMIT ${SEARCH_POST_CANDIDATE_LIMIT}
|
|
`
|
|
|
|
const needle = q.toLowerCase()
|
|
const posts = postRows
|
|
.filter((row) => {
|
|
const hay = normalizeSearchContent(`${row.title}\n${row.excerpt || ''}\n${row.content || ''}`).toLowerCase()
|
|
return hay.includes(needle)
|
|
})
|
|
.slice(0, SEARCH_POST_LIMIT)
|
|
|
|
return {
|
|
tags: tagRows.map((row) => ({ name: row.name, slug: row.slug })),
|
|
posts: posts.map((row) => ({
|
|
slug: row.slug,
|
|
title: row.title,
|
|
excerpt: row.excerpt || ''
|
|
}))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사이트 설정 조회
|
|
* @returns {Promise<Object>} 사이트 설정
|
|
*/
|
|
export const getSiteSettings = async () => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getDefaultSiteSettings()
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT *
|
|
FROM site_settings
|
|
WHERE id = 1
|
|
LIMIT 1
|
|
`
|
|
|
|
return rows[0] ? mapSiteSettingsRow(rows[0]) : getDefaultSiteSettings()
|
|
}
|
|
|
|
/**
|
|
* 관리자 사이트 설정 수정
|
|
* @param {Object} input - 사이트 설정 입력값
|
|
* @returns {Promise<Object>} 수정된 사이트 설정
|
|
*/
|
|
export const updateSiteSettings = async (input) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
INSERT INTO site_settings (
|
|
id,
|
|
title,
|
|
description,
|
|
site_url,
|
|
logo_text,
|
|
logo_url,
|
|
favicon_url,
|
|
copyright_text,
|
|
show_post_updated_at,
|
|
home_cover_image_url,
|
|
home_cover_dark_image_url,
|
|
home_cover_title,
|
|
home_cover_text,
|
|
brand_color,
|
|
announcement_enabled,
|
|
announcement_text,
|
|
announcement_url,
|
|
announcement_background_color,
|
|
announcement_alignment,
|
|
signup_blocked_usernames,
|
|
ads_txt,
|
|
custom_head_code,
|
|
custom_footer_code,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
1,
|
|
${input.title},
|
|
${input.description},
|
|
${input.siteUrl},
|
|
${input.logoText || '井'},
|
|
${input.logoUrl || ''},
|
|
${input.faviconUrl || ''},
|
|
${input.copyrightText},
|
|
${input.showPostUpdatedAt ? true : false},
|
|
${input.homeCoverImageUrl || ''},
|
|
${input.homeCoverDarkImageUrl || ''},
|
|
${input.homeCoverTitle || ''},
|
|
${input.homeCoverText || ''},
|
|
${normalizeBrandColor(input.brandColor)},
|
|
${input.announcementEnabled ? true : false},
|
|
${input.announcementText || ''},
|
|
${input.announcementUrl || ''},
|
|
${normalizeAnnouncementBackgroundColor(input.announcementBackgroundColor)},
|
|
${normalizeAnnouncementAlignment(input.announcementAlignment)},
|
|
${JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames))},
|
|
${input.adsTxt || ''},
|
|
${input.customHeadCode || ''},
|
|
${input.customFooterCode || ''},
|
|
now()
|
|
)
|
|
ON CONFLICT (id) DO UPDATE
|
|
SET
|
|
title = EXCLUDED.title,
|
|
description = EXCLUDED.description,
|
|
site_url = EXCLUDED.site_url,
|
|
logo_text = EXCLUDED.logo_text,
|
|
logo_url = EXCLUDED.logo_url,
|
|
favicon_url = EXCLUDED.favicon_url,
|
|
copyright_text = EXCLUDED.copyright_text,
|
|
show_post_updated_at = EXCLUDED.show_post_updated_at,
|
|
home_cover_image_url = EXCLUDED.home_cover_image_url,
|
|
home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url,
|
|
home_cover_title = EXCLUDED.home_cover_title,
|
|
home_cover_text = EXCLUDED.home_cover_text,
|
|
brand_color = EXCLUDED.brand_color,
|
|
announcement_enabled = EXCLUDED.announcement_enabled,
|
|
announcement_text = EXCLUDED.announcement_text,
|
|
announcement_url = EXCLUDED.announcement_url,
|
|
announcement_background_color = EXCLUDED.announcement_background_color,
|
|
announcement_alignment = EXCLUDED.announcement_alignment,
|
|
signup_blocked_usernames = EXCLUDED.signup_blocked_usernames,
|
|
ads_txt = EXCLUDED.ads_txt,
|
|
custom_head_code = EXCLUDED.custom_head_code,
|
|
custom_footer_code = EXCLUDED.custom_footer_code,
|
|
updated_at = now()
|
|
RETURNING *
|
|
`
|
|
|
|
return mapSiteSettingsRow(rows[0])
|
|
}
|
|
|
|
/**
|
|
* 사이트 로고 URL을 수정한다.
|
|
* @param {{ logoUrl: string, faviconUrl: string }} input - 로고 URL
|
|
* @returns {Promise<Object>} 수정된 사이트 설정
|
|
*/
|
|
export const updateSiteLogo = async (input) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
INSERT INTO site_settings (
|
|
id,
|
|
logo_url,
|
|
favicon_url,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
1,
|
|
${input.logoUrl},
|
|
${input.faviconUrl},
|
|
now()
|
|
)
|
|
ON CONFLICT (id) DO UPDATE
|
|
SET
|
|
logo_url = EXCLUDED.logo_url,
|
|
favicon_url = EXCLUDED.favicon_url,
|
|
updated_at = now()
|
|
RETURNING *
|
|
`
|
|
|
|
return mapSiteSettingsRow(rows[0])
|
|
}
|
|
|
|
/**
|
|
* 메인 화면 커버 이미지 URL을 수정한다.
|
|
* @param {{ homeCoverImageUrl: string }} input - 커버 이미지 URL
|
|
* @returns {Promise<Object>} 수정된 사이트 설정
|
|
*/
|
|
export const updateSiteHomeCoverImage = async (input) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
INSERT INTO site_settings (
|
|
id,
|
|
home_cover_image_url,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
1,
|
|
${input.homeCoverImageUrl || ''},
|
|
now()
|
|
)
|
|
ON CONFLICT (id) DO UPDATE
|
|
SET
|
|
home_cover_image_url = EXCLUDED.home_cover_image_url,
|
|
updated_at = now()
|
|
RETURNING *
|
|
`
|
|
|
|
return mapSiteSettingsRow(rows[0])
|
|
}
|
|
|
|
/**
|
|
* 네비게이션 항목 목록 조회
|
|
* @param {Object} options - 조회 옵션
|
|
* @param {boolean} options.visibleOnly - 표시 항목만 조회할지 여부
|
|
* @returns {Promise<Array>} 네비게이션 항목 목록
|
|
*/
|
|
export const listNavigationItems = async ({ visibleOnly = false } = {}) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getDefaultNavigationItems()
|
|
.filter((item) => !visibleOnly || item.isVisible)
|
|
}
|
|
|
|
const rows = visibleOnly
|
|
? await sql`
|
|
SELECT *
|
|
FROM navigation_items
|
|
WHERE is_visible = true
|
|
ORDER BY location ASC, sort_order ASC, label ASC
|
|
`
|
|
: await sql`
|
|
SELECT *
|
|
FROM navigation_items
|
|
ORDER BY location ASC, sort_order ASC, label ASC
|
|
`
|
|
|
|
return rows.map(mapNavigationItemRow)
|
|
}
|
|
|
|
/**
|
|
* 공개 네비게이션 조회
|
|
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>, recommended: Array<Object>}>} 위치별 공개 네비게이션
|
|
*/
|
|
export const getPublicNavigation = async () => {
|
|
const flat = await listNavigationItems({ visibleOnly: true })
|
|
const primaryFlat = flat.filter((item) => item.location === 'primary')
|
|
const footerFlat = flat
|
|
.filter((item) => item.location === 'footer' && !item.parentId)
|
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
|
const recommendedFlat = flat
|
|
.filter((item) => item.location === 'recommended' && !item.parentId)
|
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
|
|
|
return {
|
|
primary: buildPublicPrimaryTree(primaryFlat),
|
|
footer: footerFlat.map((item) => ({
|
|
id: item.id,
|
|
label: item.label,
|
|
url: item.url,
|
|
isVisible: item.isVisible
|
|
})),
|
|
recommended: recommendedFlat.map((item) => ({
|
|
id: item.id,
|
|
label: item.label,
|
|
url: item.url,
|
|
descriptionText: item.descriptionText,
|
|
thumbnailUrl: item.thumbnailUrl,
|
|
isVisible: item.isVisible
|
|
}))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 관리자 네비게이션 항목 일괄 저장
|
|
* @param {Array<Object>} items - 저장할 네비게이션 항목 목록
|
|
* @returns {Promise<Array>} 저장된 네비게이션 항목 목록
|
|
*/
|
|
export const updateNavigationItems = async (items) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
await sql.begin(async (transaction) => {
|
|
await transaction`
|
|
DELETE FROM navigation_items
|
|
`
|
|
|
|
const ordered = orderNavigationItemsForInsert(items)
|
|
for (const item of ordered) {
|
|
await transaction`
|
|
INSERT INTO navigation_items (
|
|
id,
|
|
label,
|
|
url,
|
|
description_text,
|
|
thumbnail_url,
|
|
location,
|
|
sort_order,
|
|
is_visible,
|
|
parent_id,
|
|
is_folder
|
|
)
|
|
VALUES (
|
|
${item.id},
|
|
${item.label},
|
|
${item.url},
|
|
${item.descriptionText || ''},
|
|
${item.thumbnailUrl || ''},
|
|
${item.location},
|
|
${item.sortOrder},
|
|
${item.isVisible},
|
|
${item.parentId ?? null},
|
|
${Boolean(item.isFolder)}
|
|
)
|
|
`
|
|
}
|
|
})
|
|
|
|
return listNavigationItems()
|
|
}
|
|
|
|
/**
|
|
* 관리자 태그 상세 조회
|
|
* @param {string} id - 태그 ID
|
|
* @returns {Promise<Object | null>} 태그 상세
|
|
*/
|
|
export const getAdminTagById = async (id) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
return getSampleTags().find((tag) => tag.id === id) || null
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT *
|
|
FROM tags
|
|
WHERE id = ${id}
|
|
LIMIT 1
|
|
`
|
|
|
|
return rows[0] ? mapTagRow(rows[0]) : null
|
|
}
|
|
|
|
/**
|
|
* 관리자 태그 생성
|
|
* @param {Object} input - 태그 입력값
|
|
* @returns {Promise<Object>} 생성된 태그
|
|
*/
|
|
export const createAdminTag = async (input) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
INSERT INTO tags (name, slug, description, sort_order, color, tag_type)
|
|
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color}, ${input.tagType})
|
|
RETURNING *
|
|
`
|
|
|
|
return mapTagRow(rows[0])
|
|
}
|
|
|
|
/**
|
|
* 관리자 태그 수정
|
|
* @param {string} id - 태그 ID
|
|
* @param {Object} input - 태그 입력값
|
|
* @returns {Promise<Object | null>} 수정된 태그
|
|
*/
|
|
export const updateAdminTag = async (id, input) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
UPDATE tags
|
|
SET
|
|
name = ${input.name},
|
|
slug = ${input.slug},
|
|
description = ${input.description},
|
|
sort_order = ${input.sortOrder},
|
|
color = ${input.color},
|
|
tag_type = ${input.tagType},
|
|
updated_at = now()
|
|
WHERE id = ${id}
|
|
RETURNING *
|
|
`
|
|
|
|
return rows[0] ? mapTagRow(rows[0]) : null
|
|
}
|
|
|
|
/**
|
|
* 관리자 관리용 태그 순서를 일괄 갱신
|
|
* @param {Array<string>} tagIds - 정렬된 태그 ID 목록
|
|
* @returns {Promise<Array>} 갱신된 태그 목록
|
|
*/
|
|
export const reorderManagedTags = async (tagIds) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
await sql.begin(async (transaction) => {
|
|
for (let index = 0; index < tagIds.length; index += 1) {
|
|
const tagId = tagIds[index]
|
|
await transaction`
|
|
UPDATE tags
|
|
SET
|
|
sort_order = ${(index + 1) * 10},
|
|
updated_at = now()
|
|
WHERE id = ${tagId}
|
|
AND tag_type = 'managed'
|
|
`
|
|
}
|
|
})
|
|
|
|
return listTags({ tagType: 'managed' })
|
|
}
|
|
|
|
/**
|
|
* 관리자 태그 삭제
|
|
* @param {string} id - 태그 ID
|
|
* @returns {Promise<boolean>} 삭제 여부
|
|
*/
|
|
export const deleteAdminTag = async (id) => {
|
|
const sql = getPostgresClient()
|
|
|
|
if (!sql) {
|
|
throw new Error('DATABASE_REQUIRED')
|
|
}
|
|
|
|
const rows = await sql`
|
|
DELETE FROM tags
|
|
WHERE id = ${id}
|
|
RETURNING id
|
|
`
|
|
|
|
return Boolean(rows[0])
|
|
}
|