사이트 코드와 홈페이지 위젯 추가 v1.5.34
This commit is contained in:
72
server/api/homepage-widget.get.js
Normal file
72
server/api/homepage-widget.get.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { getSiteSettings } from '../repositories/content-repository'
|
||||
import { getAnalyticsSummary } from '../repositories/analytics-repository'
|
||||
|
||||
/**
|
||||
* 초 단위 체류 시간을 짧은 한국어 문자열로 변환한다.
|
||||
* @param {number} seconds - 초 단위 시간
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatDuration = (seconds) => {
|
||||
const safeSeconds = Math.max(0, Math.round(Number(seconds) || 0))
|
||||
|
||||
if (safeSeconds < 60) {
|
||||
return `${safeSeconds}초`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(safeSeconds / 60)
|
||||
const restSeconds = safeSeconds % 60
|
||||
|
||||
return restSeconds > 0 ? `${minutes}분 ${restSeconds}초` : `${minutes}분`
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 한국어 단위 문자열로 변환한다.
|
||||
* @param {number} value - 숫자 값
|
||||
* @param {string} unit - 단위
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatNumberLabel = (value, unit) => `${Number(value || 0).toLocaleString('ko-KR')}${unit}`
|
||||
|
||||
/**
|
||||
* gethomepage 커스텀 API용 사이트 요약 위젯 데이터를 반환한다.
|
||||
* @returns {Promise<Object>} 위젯 데이터
|
||||
*/
|
||||
export default defineEventHandler(async () => {
|
||||
const [settings, summary] = await Promise.all([
|
||||
getSiteSettings(),
|
||||
getAnalyticsSummary({ days: 1 })
|
||||
])
|
||||
const avgEngagedSeconds = summary.todayAvgEngagedSeconds || summary.avgEngagedSeconds || 0
|
||||
|
||||
return {
|
||||
title: settings.title || 'sori.studio',
|
||||
updatedAt: new Date().toISOString(),
|
||||
todayVisitors: summary.todayVisitors,
|
||||
todayPageViews: summary.todayPageViews,
|
||||
onlineNow: summary.onlineNow,
|
||||
loggedInNow: summary.loggedInNow,
|
||||
avgEngagedSeconds,
|
||||
items: [
|
||||
{
|
||||
name: '오늘 방문자',
|
||||
label: formatNumberLabel(summary.todayVisitors, '명'),
|
||||
value: summary.todayVisitors
|
||||
},
|
||||
{
|
||||
name: '오늘 페이지뷰',
|
||||
label: formatNumberLabel(summary.todayPageViews, '회'),
|
||||
value: summary.todayPageViews
|
||||
},
|
||||
{
|
||||
name: '현재 접속자',
|
||||
label: formatNumberLabel(summary.onlineNow, '명'),
|
||||
value: summary.onlineNow
|
||||
},
|
||||
{
|
||||
name: '평균 체류',
|
||||
label: formatDuration(avgEngagedSeconds),
|
||||
value: avgEngagedSeconds
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
47
server/plugins/site-custom-code.js
Normal file
47
server/plugins/site-custom-code.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getSiteSettings } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공통 코드 삽입을 건너뛸 서버 경로인지 확인한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {boolean} 삽입 제외 여부
|
||||
*/
|
||||
const shouldSkipCustomCode = (event) => {
|
||||
const path = String(event?.path || event?.node?.req?.url || '/')
|
||||
|
||||
return path.startsWith('/admin')
|
||||
|| path.startsWith('/api')
|
||||
|| path.startsWith('/uploads')
|
||||
|| path.startsWith('/_nuxt')
|
||||
|| path.startsWith('/ads.txt')
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 조각을 응답 배열에 안전하게 추가한다.
|
||||
* @param {Array<string>} target - Nitro HTML 조각 배열
|
||||
* @param {string} code - 삽입할 HTML 코드
|
||||
* @returns {void}
|
||||
*/
|
||||
const pushHtmlCode = (target, code) => {
|
||||
const trimmed = String(code || '').trim()
|
||||
|
||||
if (!trimmed || !Array.isArray(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
target.push(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 HTML 응답에 관리자 설정의 헤더·푸터 코드를 삽입한다.
|
||||
*/
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('render:html', async (html, { event }) => {
|
||||
if (shouldSkipCustomCode(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const settings = await getSiteSettings()
|
||||
pushHtmlCode(html.head, settings.customHeadCode)
|
||||
pushHtmlCode(html.bodyAppend, settings.customFooterCode)
|
||||
})
|
||||
})
|
||||
@@ -542,6 +542,8 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
if (!sql) {
|
||||
return {
|
||||
todayVisitors: 0,
|
||||
todayPageViews: 0,
|
||||
todayAvgEngagedSeconds: 0,
|
||||
visitorsLast7Days: 0,
|
||||
pageViewsLast30Days: 0,
|
||||
onlineNow: 0,
|
||||
@@ -630,9 +632,15 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
|
||||
const engagedViews = Number(engagementRows[0]?.engaged_views || 0)
|
||||
const totalEngagedSeconds = Number(engagementRows[0]?.total_engaged_seconds || 0)
|
||||
const todayEngagedViews = Number(todayRows[0]?.engaged_views || 0)
|
||||
const todayTotalEngagedSeconds = Number(todayRows[0]?.total_engaged_seconds || 0)
|
||||
|
||||
return {
|
||||
todayVisitors: Number(todayRows[0]?.visitors || 0),
|
||||
todayPageViews: Number(todayRows[0]?.page_views || 0),
|
||||
todayAvgEngagedSeconds: todayEngagedViews > 0
|
||||
? Math.round(todayTotalEngagedSeconds / todayEngagedViews)
|
||||
: 0,
|
||||
visitorsLast7Days: Number(last7Rows[0]?.visitors || 0),
|
||||
pageViewsLast30Days: Number(pageViewRows[0]?.page_views || 0),
|
||||
onlineNow: Number(onlineRows[0]?.online_now || 0),
|
||||
|
||||
@@ -110,6 +110,9 @@ const mapSiteSettingsRow = (row) => ({
|
||||
announcementUrl: row.announcement_url || '',
|
||||
announcementBackgroundColor: row.announcement_background_color || '#15171a',
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -874,6 +877,9 @@ export const updateSiteSettings = async (input) => {
|
||||
announcement_url,
|
||||
announcement_background_color,
|
||||
signup_blocked_usernames,
|
||||
ads_txt,
|
||||
custom_head_code,
|
||||
custom_footer_code,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
@@ -895,6 +901,9 @@ export const updateSiteSettings = async (input) => {
|
||||
${input.announcementUrl || ''},
|
||||
${input.announcementBackgroundColor || '#15171a'},
|
||||
${JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames))},
|
||||
${input.adsTxt || ''},
|
||||
${input.customHeadCode || ''},
|
||||
${input.customFooterCode || ''},
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
@@ -916,6 +925,9 @@ export const updateSiteSettings = async (input) => {
|
||||
announcement_url = EXCLUDED.announcement_url,
|
||||
announcement_background_color = EXCLUDED.announcement_background_color,
|
||||
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 *
|
||||
`
|
||||
|
||||
26
server/routes/ads.txt.get.js
Normal file
26
server/routes/ads.txt.get.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { setHeader } from 'h3'
|
||||
import { getSiteSettings } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* ads.txt 본문을 정규화한다.
|
||||
* @param {string} value - 관리자 설정에 저장된 ads.txt 본문
|
||||
* @returns {string} text/plain 응답 본문
|
||||
*/
|
||||
const normalizeAdsTxt = (value) => {
|
||||
const text = String(value || '').trim()
|
||||
|
||||
return text ? `${text}\n` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트 ads.txt 응답
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<string>} ads.txt 본문
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
|
||||
|
||||
const settings = await getSiteSettings()
|
||||
|
||||
return normalizeAdsTxt(settings.adsTxt)
|
||||
})
|
||||
@@ -30,7 +30,10 @@ export const adminSiteSettingsInputSchema = z.object({
|
||||
announcementBackgroundColor: z.string().trim().optional().default(DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR),
|
||||
signupBlockedUsernames: z.array(
|
||||
z.string().trim().min(1).max(MAX_SIGNUP_BLOCKED_USERNAME_LENGTH)
|
||||
).max(MAX_SIGNUP_BLOCKED_USERNAME_COUNT).optional().default([...DEFAULT_SIGNUP_BLOCKED_USERNAMES])
|
||||
).max(MAX_SIGNUP_BLOCKED_USERNAME_COUNT).optional().default([...DEFAULT_SIGNUP_BLOCKED_USERNAMES]),
|
||||
adsTxt: z.string().max(20000).optional().default(''),
|
||||
customHeadCode: z.string().max(50000).optional().default(''),
|
||||
customFooterCode: z.string().max(50000).optional().default('')
|
||||
}).superRefine((data, ctx) => {
|
||||
if (!isValidAnnouncementBackgroundColor(data.announcementBackgroundColor)) {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -26,6 +26,9 @@ export const getDefaultSiteSettings = () => {
|
||||
announcementUrl: '',
|
||||
announcementBackgroundColor: '#15171a',
|
||||
signupBlockedUsernames: [...DEFAULT_SIGNUP_BLOCKED_USERNAMES],
|
||||
adsTxt: '',
|
||||
customHeadCode: '',
|
||||
customFooterCode: '',
|
||||
updatedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user