CHORE(merge): merge from develop
- Initial setup and all features from develop branch - Includes: auth, deploy, docker, style fixes - K3S deployment configuration
This commit is contained in:
174
nextjs/lib/api/http.ts
Normal file
174
nextjs/lib/api/http.ts
Normal 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
2
nextjs/lib/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './http';
|
||||
|
||||
73
nextjs/lib/auth.ts
Normal file
73
nextjs/lib/auth.ts
Normal 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
15
nextjs/lib/prisma.ts
Normal 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
44
nextjs/lib/s3.ts
Normal 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;
|
||||
}
|
||||
87
nextjs/lib/services/announcement.ts
Normal file
87
nextjs/lib/services/announcement.ts
Normal 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));
|
||||
}
|
||||
|
||||
32
nextjs/lib/services/auth.ts
Normal file
32
nextjs/lib/services/auth.ts
Normal 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);
|
||||
}
|
||||
|
||||
63
nextjs/lib/services/disciple.ts
Normal file
63
nextjs/lib/services/disciple.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
93
nextjs/lib/services/file.ts
Normal file
93
nextjs/lib/services/file.ts
Normal 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");
|
||||
}
|
||||
|
||||
143
nextjs/lib/services/gallery.ts
Normal file
143
nextjs/lib/services/gallery.ts
Normal 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;
|
||||
}
|
||||
18
nextjs/lib/services/index.ts
Normal file
18
nextjs/lib/services/index.ts
Normal 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";
|
||||
|
||||
69
nextjs/lib/services/worship.ts
Normal file
69
nextjs/lib/services/worship.ts
Normal 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
116
nextjs/lib/tabs.ts
Normal 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 || "#",
|
||||
}));
|
||||
};
|
||||
2
nextjs/lib/utils/index.ts
Normal file
2
nextjs/lib/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./pagination";
|
||||
export * from "./youtube";
|
||||
42
nextjs/lib/utils/pagination.ts
Normal file
42
nextjs/lib/utils/pagination.ts
Normal 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
184
nextjs/lib/utils/seo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
93
nextjs/lib/utils/youtube.ts
Normal file
93
nextjs/lib/utils/youtube.ts
Normal 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}` : '';
|
||||
}
|
||||
Reference in New Issue
Block a user