Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/admin/src/pages/api/cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: {
result: { uploadURL },
},
} = await api.post(`${process.env.CLOUDFLARE_REQ_URL}`, null, {
} = await api.post(`${process.env.CLOUDFLARE_REQ_IMG_URL}`, null, {
headers: {
ContentType: "application/json",
Authorization: `Bearer ${process.env.CLOUDFLARE_API_KEY}`,
Expand Down
33 changes: 28 additions & 5 deletions apps/client/src/components/homeWorship/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,38 @@ const HomeWorshipCreate = () => {
const onSubmit: SubmitHandler<IHomeWorshipForm> = async (data) => {
const formData = new FormData();

if (data.image.length === 0 || !data.date || !data.title || !data.password || !data.userName || !content)
// FIXME: 추후 validate 및 디자인 변경해야 함
if (!data.date || !data.title || !data.password || !data.userName || !content) {
return alert("모든 정보를 입력해주세요.");
if (data.image.length !== 1) return alert("사진은 한 장 업로드 가능합니다.");
}

if (data.image.length === 0 && data.video.length === 0) {
return alert("사진 또는 영상은 반드시 업로드해야 합니다.");
}

if (data.image.length > 1) {
return alert("사진은 1장만 가능 합니다.");
}

if (data.video.length > 1) {
return alert("영상은 1개만 가능 합니다.");
}

formData.append("title", data.title);
formData.append("date", data.date);
formData.append("content", content);
formData.append("password", data.password);
formData.append("userName", data.userName);

Array.from(data.image).forEach((image) => {
formData.append(`image-file`, image);
formData.append(`image-name`, image.name);
const imageFile = data.image || [];
const videoFile = data.video || [];

Array.from(imageFile).forEach((image) => {
formData.append("image-file", image);
});

Array.from(videoFile).forEach((video) => {
formData.append("video-file", video);
});

mutate(formData, {
Expand Down Expand Up @@ -70,6 +89,10 @@ const HomeWorshipCreate = () => {
<p className="text-xl font-bold">사진 업로드</p>
<input type="file" accept="image/*" {...register("image")} />
</label>
<label className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-5">
<p className="text-xl font-bold">영상 업로드</p>
<input type="file" accept="video/*" {...register("video")} />
</label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-5">
<p className="text-xl font-bold">글</p>
<Editor setValue={setContent} />
Expand Down
21 changes: 14 additions & 7 deletions apps/client/src/components/homeWorship/detail/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,28 @@ const HomeWorshipDetailComment = () => {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2">
<p>이름</p>
<input {...register("name")} className="rounded-md border border-gray-200 px-2 py-1" />
<p className="text-sm md:text-base">이름</p>
<input
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

input 들이 비슷한데 컴포넌트로 분리는 어떤가요

{...register("name")}
className="focus:ring-main-color max-w-20 rounded-md border border-gray-600 px-2 py-1 transition duration-300 focus:outline-none focus:ring-2 focus:ring-inset md:max-w-40"
/>
</label>
<div className="h-[24px] w-[1px] bg-gray-300" />
<label className="flex items-center gap-2">
<p>비밀번호</p>
<input {...register("password")} type="password" className="rounded-md border border-gray-200 px-2 py-1" />
<p className="text-sm md:text-base">비밀번호</p>
<input
{...register("password")}
type="password"
className="focus:ring-main-color max-w-24 rounded-md border border-gray-600 px-2 py-1 transition duration-300 focus:outline-none focus:ring-2 focus:ring-inset md:max-w-40"
/>
</label>
</div>
<button className="rounded-md bg-blue-500 px-2 py-1 text-white">등록</button>
<button className="rounded-md bg-blue-500 px-2 py-1 text-sm text-white md:text-base">등록</button>
</div>
<textarea
{...register("content")}
placeholder="내용을 입력하세요"
className="min-h-[100px] w-full rounded-md border border-gray-200 px-2 py-1"
placeholder="내용을 입력하세요."
className="min-h-[100px] w-full rounded-md border border-gray-600 px-2 py-1 transition duration-300 focus:outline-none focus:ring-2 focus:ring-inset"
/>
</form>
);
Expand Down
19 changes: 12 additions & 7 deletions apps/client/src/components/homeWorship/detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@ import dayjs from "dayjs";
import { useParams, usePathname, useRouter } from "next/navigation";
import { SafeHTML, Spinner } from "ui";
import HomeWorshipDetailComments from "./comments";
import HomeWorshipVideoPlayer from "./videoPlayer";

const HomeWorshipDetail = () => {
const { push } = useRouter();
const pathname = usePathname();
const params = useParams();
const homeWorshipId = params?.id as string;

const { data } = useGetHomeWorship({ homeWorshipId });
const { data, isLoading, isError } = useGetHomeWorship({ homeWorshipId });
const { mutate } = useDeleteHomeWorship();

if (!data)
if (isLoading || isError || !data || !data.homeWorship) {
return (
<div className="flex justify-center">
<Spinner />
</div>
);
}

const homeWorship = data.homeWorship;

Expand All @@ -44,7 +46,7 @@ const HomeWorshipDetail = () => {
};

return (
<div className="px-5 sm:px-10 md:px-20 lg:px-28 xl:px-36 2xl:mx-auto 2xl:max-w-screen-lg 2xl:px-40">
<div className="overflow-x-hidden px-5 sm:px-10 md:px-20 lg:px-28 xl:px-36 2xl:mx-auto 2xl:max-w-screen-lg 2xl:px-40">
<h1 className="mb-2 font-SCoreDream text-3xl">{homeWorship.title}</h1>
<div className="flex justify-between">
<div className="flex gap-2">
Expand All @@ -61,11 +63,14 @@ const HomeWorshipDetail = () => {
</button>
</div>
</div>
<div className="mb-10">
<div className="mb-10 flex w-full flex-col items-center justify-center gap-5">
<SafeHTML html={homeWorship.content} />
<div className="relative h-full w-full">
<img src={`${homeWorship.image}/full`} alt="이미지" />
</div>
{homeWorship.image && (
<div className="relative flex h-full w-full justify-center">
<img src={`${homeWorship.image}/full`} alt="이미지" />
</div>
)}
{homeWorship.video && <HomeWorshipVideoPlayer video={homeWorship.video} />}
</div>
<HomeWorshipDetailComments />
</div>
Expand Down
32 changes: 32 additions & 0 deletions apps/client/src/components/homeWorship/detail/videoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useState } from "react";
import { Spinner, cn } from "ui";

interface IHomeWorshipVideoPlayerProps {
video: string;
}

const HomeWorshipVideoPlayer = ({ video }: IHomeWorshipVideoPlayerProps) => {
const [loading, setLoading] = useState(true);

return (
<div className="relative aspect-[16/9] w-full">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
<Spinner />
</div>
)}
<iframe
className={cn(
"absolute top-0 h-full w-full rounded-md border-none transition-opacity duration-500",
loading ? "opacity-0" : "opacity-100",
)}
src={video}
onLoad={() => setLoading(false)}
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowFullScreen
/>
</div>
);
};

export default HomeWorshipVideoPlayer;
87 changes: 67 additions & 20 deletions apps/client/src/pages/api/cloudflare/index.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,80 @@
import { api } from "@/api";
import { NextApiRequest, NextApiResponse } from "next";

interface IRequestOptions {
expiry: string;
maxDurationSeconds?: number;
requireSignedURLs?: boolean;
allowedOrigins?: string[];
creator?: string;
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const {
method,
body: { expireDate },
body: { userName, title, date, expireDate, type },
} = req;

switch (method) {
// Direct Creator Upload URL 가져오기
case "POST":
// CloudFlare DirectCreatorUpload 3시간 유효한 uploadURL 가져오기
const {
data: {
result: { uploadURL },
},
} = await api.post(`${process.env.CLOUDFLARE_REQ_URL}`, null, {
headers: {
ContentType: "application/json",
Authorization: `Bearer ${process.env.CLOUDFLARE_API_KEY}`,
},
data: {
expiry: expireDate,
},
});

return res.status(200).json({
success: uploadURL ? true : false,
uploadURL,
});
const reqTypes = {
video: process.env.CLOUDFLARE_REQ_VIDEO_URL,
image: process.env.CLOUDFLARE_REQ_IMG_URL,
};

const fileTypes = type as keyof typeof reqTypes;

// Request 옵션 설정
const options: IRequestOptions = {
expiry: expireDate,
};
if (fileTypes === "video") {
options.maxDurationSeconds = 300; // 최대길이 5분(60초*5)
options.creator = userName;
}
// Request URL 설정
const reqURL = reqTypes[fileTypes];

if (reqURL) {
switch (fileTypes) {
// 1. IMG Direct Upload
case "image":
const {
data: {
result: { uploadURL: imgUploadURL },
},
} = await api.post(reqURL, null, {
headers: {
Authorization: `Bearer ${process.env.CLOUDFLARE_API_KEY}`,
ContentType: "application/json",
},
data: options,
});
return res.status(200).json({
success: imgUploadURL ? true : false,
uploadURL: imgUploadURL,
});

// 2. VIDEO Direct Upload
case "video":
const {
data: {
result: { uploadURL: videoUploadURL },
},
} = await api.post(reqURL, options, {
headers: {
Authorization: `Bearer ${process.env.CLOUDFLARE_API_KEY}`,
"Content-Type": "application/json",
},
});
return res.status(200).json({
success: videoUploadURL ? true : false,
uploadURL: videoUploadURL,
});
}
}
return res.status(405).end("Request URL이 존재하지 않습니다.");

default:
res.setHeader("Allow", ["POST"]);
Expand Down
Loading