사이트 코드와 홈페이지 위젯 추가 v1.5.34

This commit is contained in:
2026-06-02 14:21:47 +09:00
parent 600b0fd1d9
commit 5b78a8c92f
21 changed files with 618 additions and 39 deletions

View 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
}
]
}
})

View 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)
})
})

View File

@@ -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),

View File

@@ -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 *
`

View 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)
})

View File

@@ -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({

View File

@@ -26,6 +26,9 @@ export const getDefaultSiteSettings = () => {
announcementUrl: '',
announcementBackgroundColor: '#15171a',
signupBlockedUsernames: [...DEFAULT_SIGNUP_BLOCKED_USERNAMES],
adsTxt: '',
customHeadCode: '',
customFooterCode: '',
updatedAt: null
}
}