CHORE(merge): merge from develop
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
CI / lint-and-build (push) Has been cancelled

- Initial setup and all features from develop branch
- Includes: auth, deploy, docker, style fixes
- K3S deployment configuration
This commit is contained in:
2026-01-06 17:29:16 +09:00
parent b4ce36ba3b
commit f78454c2a1
159 changed files with 18365 additions and 774 deletions

174
nextjs/lib/api/http.ts Normal file
View File

@@ -0,0 +1,174 @@
// 공통 헤더
const getDefaultHeaders = () => ({
'Content-Type': 'application/json',
});
// 인증 헤더 (쿠키 포함)
const getAuthHeaders = () => ({
...getDefaultHeaders(),
credentials: 'include' as const,
});
// 기본 fetch 래퍼
const apiRequest = async (
url: string,
options: RequestInit = {}
): Promise<Response> => {
const response = await fetch(url, {
...options,
headers: {
...getDefaultHeaders(),
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
let errorMessage = errorText;
// JSON 응답인지 확인하고 message 필드 추출
try {
const errorJson = JSON.parse(errorText);
if (errorJson.message) {
errorMessage = errorJson.message;
}
} catch {
// JSON 파싱 실패 시 무시
}
throw new Error(errorMessage);
}
return response;
};
// GET 요청
export const apiGet = async <T>(url: string): Promise<T> => {
try {
const response = await apiRequest(url);
const result = await response.json();
// success 필드가 있고 data 필드가 있으면 data를 반환
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.data;
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
throw new Error(errorMessage);
}
};
// POST 요청
export const apiPost = async <TResponse, TBody = unknown>(
url: string,
data: TBody
): Promise<TResponse> => {
try {
const response = await apiRequest(url, {
method: 'POST',
body: JSON.stringify(data),
});
const result = await response.json();
// success 필드가 있고 data 필드가 있으면 data를 반환
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.data as TResponse;
}
return result as TResponse;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
throw new Error(errorMessage);
}
};
// PUT 요청
export const apiPut = async <TResponse, TBody = unknown>(
url: string,
data: TBody
): Promise<TResponse> => {
try {
const response = await apiRequest(url, {
method: 'PUT',
body: JSON.stringify(data),
});
const result = await response.json();
// success 필드가 있고 data 필드가 있으면 data를 반환
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.data as TResponse;
}
return result as TResponse;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
throw new Error(errorMessage);
}
};
// DELETE 요청
export const apiDelete = async <T>(url: string): Promise<T> => {
try {
const response = await apiRequest(url, {
method: 'DELETE',
});
const result = await response.json();
// success 필드가 있고 data 필드가 있으면 data를 반환
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.data;
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
throw new Error(errorMessage);
}
};
// 인증 GET 요청
export const apiGetWithAuth = async <TResponse>(url: string): Promise<TResponse> => {
const response = await apiRequest(url, {
headers: getAuthHeaders(),
});
return response.json();
};
// 인증 POST 요청
export const apiPostWithAuth = async <TResponse, TBody = unknown>(
url: string,
data: TBody
): Promise<TResponse> => {
const response = await apiRequest(url, {
method: 'POST',
body: JSON.stringify(data),
headers: getAuthHeaders(),
});
return response.json();
};
// 인증 PUT 요청
export const apiPutWithAuth = async <TResponse, TBody = unknown>(
url: string,
data: TBody
): Promise<TResponse> => {
const response = await apiRequest(url, {
method: 'PUT',
body: JSON.stringify(data),
headers: getAuthHeaders(),
});
return response.json();
};
// 인증 DELETE 요청
export const apiDeleteWithAuth = async <T>(url: string): Promise<T> => {
const response = await apiRequest(url, {
method: 'DELETE',
headers: getAuthHeaders(),
});
return response.json();
};

2
nextjs/lib/api/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './http';

73
nextjs/lib/auth.ts Normal file
View File

@@ -0,0 +1,73 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/prisma";
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
secret: process.env.AUTH_SECRET || process.env.JWT_SECRET,
providers: [
Credentials({
name: "Credentials",
credentials: {
userId: { label: "아이디", type: "text" },
userPassword: { label: "비밀번호", type: "password" },
},
async authorize(credentials) {
if (!credentials?.userId || !credentials?.userPassword) {
return null;
}
const user = await prisma.user.findUnique({
where: { userId: credentials.userId as string },
});
if (!user) {
return null;
}
const isPasswordCorrect = await compare(
credentials.userPassword as string,
user.userPassword
);
if (!isPasswordCorrect) {
return null;
}
return {
id: user.id.toString(),
userId: user.userId,
name: user.userName,
email: user.userPhone,
};
},
}),
],
session: {
strategy: "jwt",
maxAge: 3 * 24 * 60 * 60, // 3일
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.userId = user.userId;
token.name = user.name;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
session.user.userId = token.userId as string;
session.user.name = token.name as string;
}
return session;
},
},
pages: {
signIn: "/login",
},
});

15
nextjs/lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import 'dotenv/config';
import { PrismaClient } from '@/prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

44
nextjs/lib/s3.ts Normal file
View File

@@ -0,0 +1,44 @@
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3_CONFIG, s3Client } from '@/const';
/**
* S3 파일의 signed URL 생성 (1시간 유효)
*/
export async function generateSignedUrl(
fileKey: string,
options?: {
fileName?: string;
expiresIn?: number;
}
): Promise<string> {
const command = new GetObjectCommand({
Bucket: S3_CONFIG.BUCKET_NAME,
Key: fileKey,
...(options?.fileName && {
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(options.fileName)}"`,
}),
});
return getSignedUrl(s3Client, command, {
expiresIn: options?.expiresIn || 3600
});
}
/**
* 여러 파일의 signed URL 일괄 생성
*/
export async function generateSignedUrls(
fileKeys: string[]
): Promise<Map<string, string>> {
const urlMap = new Map<string, string>();
await Promise.all(
fileKeys.map(async (fileKey) => {
const url = await generateSignedUrl(fileKey);
urlMap.set(fileKey, url);
})
);
return urlMap;
}

View File

@@ -0,0 +1,87 @@
import { apiGet, apiPost, apiDelete } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
import { PaginatedResponse } from "@/lib/utils";
export interface AnnouncementAuthor {
userId: string;
userName: string;
}
export interface AnnouncementFile {
id: number;
fileKey: string;
fileName: string;
fileSize?: number | null;
mimeType?: string | null;
createdAt: string;
updatedAt: string;
signedUrl?: string;
}
export interface Announcement {
id: number;
title: string;
content: string;
isImportant: boolean;
viewCount: number;
authorId: number;
createdAt: string;
updatedAt: string;
author: AnnouncementAuthor;
files?: AnnouncementFile[];
}
export interface CreateAnnouncementData {
title: string;
content?: string;
isImportant: boolean;
authorId: number;
files?: {
fileKey: string;
fileName: string;
fileSize?: number;
mimeType?: string;
}[];
}
export type AnnouncementPaginatedResponse = PaginatedResponse<Announcement>;
/**
* 공지사항 목록 조회 (pagination)
*/
export async function getAnnouncements(
page = 1,
limit = 10
): Promise<AnnouncementPaginatedResponse> {
const url = `${API_ENDPOINTS.ANNOUNCEMENT.BASE}?page=${page}&limit=${limit}`;
const response = await fetch(url);
const result = await response.json();
return {
data: result.data,
pagination: result.pagination,
};
}
/**
* 공지사항 상세 조회
*/
export async function getAnnouncementById(id: number): Promise<Announcement> {
return apiGet<Announcement>(API_ENDPOINTS.ANNOUNCEMENT.BY_ID(id));
}
/**
* 공지사항 생성
*/
export async function createAnnouncement(
data: CreateAnnouncementData
): Promise<Announcement> {
return apiPost<Announcement>(API_ENDPOINTS.ANNOUNCEMENT.BASE, data);
}
/**
* 공지사항 삭제
*/
export async function deleteAnnouncement(id: number): Promise<void> {
return apiDelete<void>(API_ENDPOINTS.ANNOUNCEMENT.BY_ID(id));
}

View File

@@ -0,0 +1,32 @@
import { apiGet, apiPost } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
export interface User {
id: number;
userId: string;
userName: string;
}
export interface SignUpData {
userId: string;
userPassword: string;
userCheckPassword: string;
userName: string;
userPhone: string;
authCode?: string;
}
/**
* 현재 로그인한 사용자 정보 조회
*/
export async function getMe(): Promise<User> {
return apiGet<User>(API_ENDPOINTS.AUTH.ME);
}
/**
* 회원가입
*/
export async function signUp(data: SignUpData): Promise<void> {
return apiPost<void>(API_ENDPOINTS.AUTH.SIGN_UP, data);
}

View File

@@ -0,0 +1,63 @@
import { apiGet, apiPost, apiDelete, apiPut } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
export interface DiscipleVideo {
id: number;
stage: string;
step: string | null;
videoUrl: string;
order: number;
createdAt: string;
updatedAt: string;
thumbnailUrl?: string;
embedUrl?: string;
}
export interface DiscipleVideoData {
stage: string;
step?: string | null;
videoUrl: string;
}
interface DiscipleReorderData {
videoId1: number;
videoId2: number;
}
/**
* 모든 제자훈련 영상 가져오기
*/
export async function getAllDiscipleVideos(): Promise<DiscipleVideo[]> {
return apiGet<DiscipleVideo[]>(API_ENDPOINTS.DISCIPLE.BASE);
}
/**
* 특정 stage의 영상들 가져오기
*/
export async function getDiscipleVideosByStage(stage: string): Promise<DiscipleVideo[]> {
return apiGet<DiscipleVideo[]>(API_ENDPOINTS.DISCIPLE.BY_STAGE(stage));
}
/**
* 영상 생성
*/
export async function createDiscipleVideo(data: DiscipleVideoData): Promise<DiscipleVideo> {
return apiPost<DiscipleVideo>(API_ENDPOINTS.DISCIPLE.BASE, data);
}
/**
* 영상 삭제
*/
export async function deleteDiscipleVideo(id: number): Promise<void> {
return apiDelete<void>(API_ENDPOINTS.DISCIPLE.BY_ID(id));
}
/**
* 영상 순서 변경 (두 비디오의 순서를 교환)
*/
export async function swapDiscipleVideos(videoId1: number, videoId2: number): Promise<DiscipleVideo[]> {
return apiPut<DiscipleVideo[], DiscipleReorderData>(
API_ENDPOINTS.DISCIPLE.REORDER,
{ videoId1, videoId2 }
);
}

View File

@@ -0,0 +1,93 @@
import { apiPost } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
export interface UploadedFile {
fileKey: string;
originalName: string;
fileType: string;
fileSize: number;
}
/**
* S3 업로드 URL 생성
*/
export async function getUploadUrl(
fileName: string,
fileType: string,
folder?: string
): Promise<{ uploadUrl: string; fileKey: string }> {
return apiPost<{ uploadUrl: string; fileKey: string }>(
API_ENDPOINTS.FILE.UPLOAD_URL,
{
fileName,
fileType,
folder,
}
);
}
/**
* S3에 파일 업로드
*/
export async function uploadToS3(uploadUrl: string, file: File): Promise<void> {
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
"Cache-Control": "no-cache",
},
});
if (!uploadResponse.ok) {
throw new Error(
`S3 업로드에 실패했습니다. (${uploadResponse.status}: ${uploadResponse.statusText})`
);
}
}
/**
* 파일 업로드 전체 프로세스
*/
export async function uploadFile(file: File, folder?: string): Promise<UploadedFile> {
// 1. 업로드 URL 생성
const { uploadUrl, fileKey } = await getUploadUrl(file.name, file.type, folder);
// 2. S3에 업로드
await uploadToS3(uploadUrl, file);
// 3. 파일 정보 반환
return {
fileKey,
originalName: file.name,
fileType: file.type,
fileSize: file.size,
};
}
/**
* S3에서 파일 다운로드 URL 생성
*/
export async function getDownloadUrl(
fileKey: string,
fileName?: string
): Promise<string> {
const result = await apiPost<{ downloadUrl: string }>(
API_ENDPOINTS.FILE.DOWNLOAD_URL,
{ fileKey, fileName }
);
return result.downloadUrl;
}
/**
* 파일 다운로드
*/
export async function downloadFile(
fileKey: string,
fileName: string
): Promise<void> {
const downloadUrl = await getDownloadUrl(fileKey, fileName);
// ResponseContentDisposition이 설정되어 있으면 자동으로 다운로드됨
window.open(downloadUrl, "_blank");
}

View File

@@ -0,0 +1,143 @@
import { apiPost, apiDelete, apiGet } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
import { uploadFile } from "./file";
import { PaginatedResponse } from "@/lib/utils";
export interface GalleryImage {
id: number;
fileKey: string;
postId: number;
order: number;
aspectRatio?: number | null;
createdAt: string;
updatedAt: string;
displayUrl?: string;
}
export interface GalleryTextBlock {
id: number;
postId: number;
content: string;
order: number;
createdAt: string;
updatedAt: string;
}
export type GalleryContentItem =
| { type: 'image'; data: GalleryImage }
| { type: 'text'; data: GalleryTextBlock };
export interface GalleryPost {
id: number;
title: string;
content: string;
createdAt: string;
updatedAt: string;
images: GalleryImage[];
textBlocks?: GalleryTextBlock[];
thumbnailUrl?: string;
sortedContent?: GalleryContentItem[];
}
export interface CreateGalleryPostData {
title: string;
content?: string;
items: Array<
| { type: 'image'; fileKey: string; order: number; aspectRatio?: number }
| { type: 'text'; content: string; order: number }
>;
}
/**
* 이미지 파일의 비율을 계산하는 함수
*/
export function calculateImageAspectRatio(file: File): Promise<number> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const aspectRatio = img.naturalWidth / img.naturalHeight;
resolve(aspectRatio);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('이미지 로드에 실패했습니다.'));
};
img.src = url;
});
}
export type GalleryPostPaginatedResponse = PaginatedResponse<GalleryPost>;
/**
* 갤러리 포스트 목록 조회 (pagination, thumbnailUrl 포함)
*/
export async function getGalleryPosts(
page = 1,
limit = 12
): Promise<GalleryPostPaginatedResponse> {
const url = `${API_ENDPOINTS.GALLERY.BASE}?page=${page}&limit=${limit}`;
const response = await fetch(url);
const result = await response.json();
return {
data: result.data,
pagination: result.pagination,
};
}
/**
* 갤러리 포스트 상세 조회 (displayUrl, sortedContent 포함)
*/
export async function getGalleryPost(id: number): Promise<GalleryPost> {
return apiGet<GalleryPost>(API_ENDPOINTS.GALLERY.BY_ID(id));
}
/**
* 갤러리 포스트의 정렬된 콘텐츠 반환 (백엔드에서 이미 정렬됨)
*/
export function getSortedGalleryContent(post: GalleryPost): GalleryContentItem[] {
// 백엔드에서 sortedContent가 제공되면 그대로 사용
if (post.sortedContent) {
return post.sortedContent;
}
// fallback: 클라이언트에서 정렬
const items: GalleryContentItem[] = [
...post.images.map((img) => ({ type: 'image' as const, data: img })),
...(post.textBlocks || []).map((text) => ({ type: 'text' as const, data: text })),
];
return items.sort((a, b) => a.data.order - b.data.order);
}
/**
* 갤러리 포스트 생성
*/
export async function createGalleryPost(
data: CreateGalleryPostData
): Promise<GalleryPost> {
return apiPost<GalleryPost>(API_ENDPOINTS.GALLERY.BASE, data);
}
/**
* 갤러리 포스트 삭제
*/
export async function deleteGalleryPost(id: number): Promise<void> {
return apiDelete<void>(API_ENDPOINTS.GALLERY.BY_ID(id));
}
/**
* 여러 파일 업로드 후 fileKeys 반환
*/
export async function uploadGalleryFiles(files: File[]): Promise<string[]> {
const fileKeys: string[] = [];
for (const file of files) {
const { fileKey } = await uploadFile(file, 'gallery');
fileKeys.push(fileKey);
}
return fileKeys;
}

View File

@@ -0,0 +1,18 @@
// Auth
export * from "./auth";
// Announcement
export * from "./announcement";
// File
export * from "./file";
// Gallery
export * from "./gallery";
// Worship
export * from "./worship";
// Disciple
export * from "./disciple";

View File

@@ -0,0 +1,69 @@
import { apiGet, apiPost, apiDelete, apiPut } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
export interface WorshipVideo {
id: number;
category: string;
videoUrl: string;
order: number;
createdAt: string;
updatedAt: string;
thumbnailUrl?: string;
embedUrl?: string;
}
export interface WorshipVideoData {
category: string;
videoUrl: string;
order: number;
}
export interface ReorderVideosData {
videoId1: number;
videoId2: number;
}
/**
* 모든 예배 영상 가져오기
*/
export async function getAllWorshipVideos(): Promise<WorshipVideo[]> {
return apiGet<WorshipVideo[]>(API_ENDPOINTS.WORSHIP.BASE);
}
/**
* 특정 카테고리의 영상들 가져오기
*/
export async function getWorshipVideosByCategory(category: string): Promise<WorshipVideo[]> {
return apiGet<WorshipVideo[]>(API_ENDPOINTS.WORSHIP.BY_CATEGORY(category));
}
/**
* 영상 생성
*/
export async function createWorshipVideo(data: WorshipVideoData): Promise<WorshipVideo> {
return apiPost<WorshipVideo>(API_ENDPOINTS.WORSHIP.BASE, data);
}
/**
* 영상 삭제
*/
export async function deleteWorshipVideo(id: number): Promise<void> {
return apiDelete<void>(API_ENDPOINTS.WORSHIP.BY_ID(id));
}
/**
* 영상 순서 변경 (두 비디오의 순서를 교환)
*/
export async function swapWorshipVideos(videoId1: number, videoId2: number): Promise<WorshipVideo[]> {
return apiPut<WorshipVideo[], ReorderVideosData>(
API_ENDPOINTS.WORSHIP.REORDER,
{ videoId1, videoId2 }
);
}
/**
* 카테고리 내 다음 order 값 가져오기
*/
export async function getNextWorshipOrder(category: string): Promise<number> {
return apiGet<number>(API_ENDPOINTS.WORSHIP.NEXT_ORDER(category));
}

116
nextjs/lib/tabs.ts Normal file
View File

@@ -0,0 +1,116 @@
import tabs from "@/const/tabs";
// 타입 정의
export interface TabInfo {
title: string;
subtitle: string;
subtitleEnglish: string;
image: string;
index: number;
description: string;
tab: typeof tabs[number];
submenu: typeof tabs[number]["submenu"][number];
}
export const getTabInfo = (pathname: string): TabInfo | null => {
// 먼저 정확히 일치하는 경로를 찾습니다
for (const [index, tab] of tabs.entries()) {
for (const item of tab.submenu) {
if (item.href === pathname) {
return {
title: tab.label,
subtitle: item.label,
subtitleEnglish: item.englishLabel,
image: tab.imageHref,
index,
description: item.description,
tab,
submenu: item,
};
}
}
}
// 정확히 일치하는 경로가 없으면, pathname이 시작하는 경로를 찾습니다
// (예: /announcements/create는 /announcements와 매칭)
for (const [index, tab] of tabs.entries()) {
for (const item of tab.submenu) {
if (pathname.startsWith(item.href + '/')) {
return {
title: tab.label,
subtitle: item.label,
subtitleEnglish: item.englishLabel,
image: tab.imageHref,
index,
description: item.description,
tab,
submenu: item,
};
}
}
}
// pathname이 tab.href와 정확히 일치하는 경우 (예: /system)
for (const [index, tab] of tabs.entries()) {
if (tab.href === pathname && tab.submenu.length > 0) {
// tab.href와 일치하는 경우, 한글 라벨 사용
const firstItem = tab.submenu[0];
// Discipling System의 경우 "제자화 시스템" 사용
const subtitle = tab.label === "Discipling System" ? "제자화 시스템" : tab.label;
const subtitleEnglish = tab.label === "Discipling System" ? "DISCIPLESHIP SYSTEM" : tab.label.toUpperCase();
return {
title: tab.label,
subtitle,
subtitleEnglish,
image: tab.imageHref,
index,
description: firstItem.description,
tab,
submenu: firstItem,
};
}
}
// pathname이 tab.href로 시작하는 경우 (예: /system/new-family는 /system과 매칭)
for (const [index, tab] of tabs.entries()) {
if (pathname.startsWith(tab.href + '/') && tab.submenu.length > 0) {
// pathname과 일치하는 submenu 항목 찾기
for (const item of tab.submenu) {
if (pathname.startsWith(item.href + '/') || pathname === item.href) {
return {
title: tab.label,
subtitle: item.label,
subtitleEnglish: item.englishLabel,
image: tab.imageHref,
index,
description: item.description,
tab,
submenu: item,
};
}
}
// 일치하는 submenu가 없으면 첫 번째 항목 사용
const firstItem = tab.submenu[0];
return {
title: tab.label,
subtitle: firstItem.label,
subtitleEnglish: firstItem.englishLabel,
image: tab.imageHref,
index,
description: firstItem.description,
tab,
submenu: firstItem,
};
}
}
return null;
};
export const getNavbarTabs = () => {
return tabs.map((tab) => ({
label: tab.label,
// 항상 첫 번째 submenu 항목으로 이동 (루트 페이지가 없으므로)
href: tab.submenu[0]?.href || "#",
}));
};

View File

@@ -0,0 +1,2 @@
export * from "./pagination";
export * from "./youtube";

View File

@@ -0,0 +1,42 @@
export interface PaginationParams {
page?: number;
limit?: number;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
export function getPaginationParams(
searchParams: URLSearchParams,
defaultLimit = 12
): { page: number; limit: number; skip: number } {
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
const limit = Math.max(1, parseInt(searchParams.get('limit') || String(defaultLimit), 10));
const skip = (page - 1) * limit;
return { page, limit, skip };
}
export function createPaginatedResponse<T>(
data: T[],
total: number,
page: number,
limit: number
): PaginatedResponse<T> {
return {
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}

184
nextjs/lib/utils/seo.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* SEO 유틸리티 함수들
*/
/**
* 페이지 제목 생성
* @param title - 페이지 제목
* @param siteName - 사이트 이름 (기본값: 제자들교회)
* @returns 완성된 페이지 제목
*/
export function generatePageTitle(title: string, siteName = '제자들교회'): string {
return `${title} | ${siteName}`;
}
/**
* 메타 설명 생성
* @param description - 설명
* @param maxLength - 최대 길이 (기본값: 160자)
* @returns 잘린 설명
*/
export function truncateDescription(description: string, maxLength = 160): string {
if (description.length <= maxLength) return description;
return description.substring(0, maxLength - 3) + '...';
}
/**
* Open Graph 이미지 URL 생성
* @param path - 이미지 경로
* @param baseUrl - 기본 URL (기본값: https://disciples-jaejadle.com)
* @returns 완전한 이미지 URL
*/
export function generateOgImageUrl(path: string, baseUrl = 'https://www.disciples-church.com'): string {
if (path.startsWith('http')) return path;
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
}
/**
* Canonical URL 생성
* @param pathname - 경로
* @param baseUrl - 기본 URL (기본값: https://disciples-jaejadle.com)
* @returns Canonical URL
*/
export function generateCanonicalUrl(pathname: string, baseUrl = 'https://www.disciples-church.com'): string {
const cleanPath = pathname.replace(/\/$/, ''); // 끝의 슬래시 제거
return `${baseUrl}${cleanPath}`;
}
/**
* 키워드 배열 생성
* @param keywords - 키워드 문자열 또는 배열
* @returns 키워드 배열
*/
export function normalizeKeywords(keywords: string | string[]): string[] {
if (Array.isArray(keywords)) return keywords;
return keywords.split(',').map(k => k.trim()).filter(Boolean);
}
/**
* 구조화된 데이터 JSON-LD 생성
* @param type - 스키마 타입
* @param data - 데이터 객체
* @returns JSON-LD 문자열
*/
export function generateJsonLd(type: string, data: Record<string, unknown>): string {
return JSON.stringify({
'@context': 'https://schema.org',
'@type': type,
...data,
});
}
/**
* 빵가루 네비게이션 데이터 생성
* @param items - 빵가루 아이템 배열
* @returns 구조화된 빵가루 데이터
*/
export function generateBreadcrumbData(items: Array<{ name: string; url: string }>) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
};
}
/**
* 날짜를 ISO 8601 형식으로 변환
* @param date - Date 객체 또는 날짜 문자열
* @returns ISO 8601 형식 문자열
*/
export function toISODate(date: Date | string): string {
if (typeof date === 'string') {
date = new Date(date);
}
return date.toISOString();
}
/**
* 한국 시간대로 ISO 8601 형식 생성
* @param date - Date 객체
* @returns ISO 8601 형식 문자열 (한국 시간대)
*/
export function toKoreanISODate(date: Date): string {
const offset = 9 * 60; // 한국은 UTC+9
const koreanDate = new Date(date.getTime() + offset * 60 * 1000);
return koreanDate.toISOString().replace('Z', '+09:00');
}
/**
* 소셜 미디어 공유 URL 생성
*/
export const socialShare = {
facebook: (url: string) => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
twitter: (url: string, text?: string) => {
const params = new URLSearchParams({ url });
if (text) params.append('text', text);
return `https://twitter.com/intent/tweet?${params.toString()}`;
},
kakao: (url: string) => `https://story.kakao.com/share?url=${encodeURIComponent(url)}`,
line: (url: string) => `https://social-plugins.line.me/lineit/share?url=${encodeURIComponent(url)}`,
};
/**
* 페이지 메타데이터 생성 헬퍼
*/
export function generatePageMetadata({
title,
description,
keywords = [],
image,
url,
type = 'website',
noIndex = false,
}: {
title: string;
description: string;
keywords?: string[];
image?: string;
url: string;
type?: 'website' | 'article';
noIndex?: boolean;
}) {
const baseUrl = 'https://www.disciples-church.com';
const fullUrl = generateCanonicalUrl(url, baseUrl);
const ogImage = image ? generateOgImageUrl(image, baseUrl) : `${baseUrl}/opengraph-image.jpg`;
return {
title,
description: truncateDescription(description),
keywords: [...keywords, '제자들교회', '인천', '교회'],
openGraph: {
title,
description,
url: fullUrl,
type,
images: [
{
url: ogImage,
width: 1200,
height: 630,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image' as const,
title,
description,
images: [ogImage],
},
alternates: {
canonical: fullUrl,
},
robots: noIndex ? {
index: false,
follow: true,
} : undefined,
};
}

View File

@@ -0,0 +1,93 @@
/**
* YouTube URL 유틸리티 함수
* worship 페이지와 disciple 페이지에서 공유하여 사용
*/
/**
* YouTube URL에서 비디오 ID를 추출
* 지원 형식: watch?v=, youtu.be/, embed/, live/
*/
export function extractYouTubeId(url: string): string | null {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
// watch?v= 형식
if (urlObj.searchParams.has('v')) {
return urlObj.searchParams.get('v');
}
// youtu.be/ID 형식
if (hostname === 'youtu.be') {
return urlObj.pathname.slice(1).split('?')[0];
}
// embed/ID 형식
if (urlObj.pathname.startsWith('/embed/')) {
return urlObj.pathname.split('/embed/')[1]?.split('?')[0] || null;
}
// live/ID 형식
if (urlObj.pathname.startsWith('/live/')) {
const liveId = urlObj.pathname.split('/live/')[1]?.split('?')[0];
return liveId || null;
}
return null;
} catch {
return null;
}
}
/**
* YouTube URL에서 썸네일 URL 생성
*/
export function getYouTubeThumbnailUrl(videoUrl: string): string {
const videoId = extractYouTubeId(videoUrl);
return videoId
? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
: '/placeholder.jpg';
}
/**
* YouTube URL 유효성 검사
*/
export function isValidYouTubeUrl(url: string): boolean {
try {
// 빈 문자열이나 whitespace만 있는 경우 거부
if (!url || url.trim().length === 0) {
return false;
}
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
// pathname에서 첫 번째 경로 세그먼트 추출
const pathSegments = urlObj.pathname.split('/').filter(segment => segment);
const firstSegment = pathSegments[0]?.toLowerCase();
// YouTube 도메인과 지원하는 경로 형식 확인
const isSupportedDomain = (
hostname === 'www.youtube.com' ||
hostname === 'youtube.com' ||
hostname === 'm.youtube.com' ||
hostname === 'youtu.be'
);
// 지원하는 경로 형식 확인 (/watch, /embed, /live 등)
const isSupportedPath = (
!firstSegment || // youtu.be/ID 형식
firstSegment === 'watch' ||
firstSegment === 'embed' ||
firstSegment === 'live'
);
return isSupportedDomain && isSupportedPath;
} catch {
return false;
}
}
/**
* YouTube embed URL 생성
*/
export function getYouTubeEmbedUrl(videoUrl: string): string {
const videoId = extractYouTubeId(videoUrl);
return videoId ? `https://www.youtube.com/embed/${videoId}` : '';
}