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 src/app/(admin)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default async function AdminLayout({
const initialAccessToken = await getServerCookie('accessToken');

return (
<html lang="ko">
<html lang="ko" className="overflow-x-hidden">
<head>{GTM_ID && <GoogleTagManager gtmId={GTM_ID} />}</head>
<body className={clsx(pretendard.className, 'h-screen w-screen')}>
<MainProvider initialAccessToken={initialAccessToken ?? undefined}>
Expand Down
2 changes: 1 addition & 1 deletion src/app/(landing)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export default async function LandingPageLayout({
<MainProvider initialAccessToken={initialAccessToken ?? undefined}>
<ClarityInit projectId={CLARITY_PROJECT_ID} />
<PageViewTracker />
<div className="w-full overflow-auto">
<div className="w-full">
{/** 1400 + 48*2 패딩 양옆 48로 임의적용 */}
<div className="m-auto flex min-w-[1496px] flex-1 flex-col items-center">
<Header />
Expand Down
2 changes: 1 addition & 1 deletion src/app/(service)/insights/ui/blog-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ export default function BlogDetailPage({ article }: BlogDetailPageProps) {
<div className="w-full">
{/* 커버 이미지 (GNB 바로 아래, 전체 너비) */}
{coverUrl && (
<div className="relative left-1/2 h-[500px] w-screen -translate-x-1/2">
<div className="relative h-[500px] w-full">
<Image
src={coverUrl}
alt={article.title}
Expand Down
4 changes: 2 additions & 2 deletions src/app/(service)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ export default async function ServiceLayout({
const initialAccessToken = await getServerCookie('accessToken');

return (
<html lang="ko">
<html lang="ko" className="overflow-x-hidden">
<head>{GTM_ID && <GoogleTagManager gtmId={GTM_ID} />}</head>
<body className={clsx(pretendard.className, 'min-h-screen w-screen')}>
<MainProvider initialAccessToken={initialAccessToken ?? undefined}>
<GlobalToast />
<ClarityInit projectId={CLARITY_PROJECT_ID} />
<PageViewTracker />
<div className="flex min-h-screen w-full flex-col overflow-x-auto">
<div className="flex min-h-screen w-full flex-col overflow-x-hidden">
<Header />
<main className="w-full flex-1">{children}</main>
<FloatingInquiryButton />
Expand Down
70 changes: 50 additions & 20 deletions src/components/card/study-card.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { Clock5, Users } from 'lucide-react';
import { Clock5, Eye, Users } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { GroupStudyListItemDto } from '@/api/openapi';
Expand Down Expand Up @@ -45,12 +45,25 @@
study: GroupStudyListItemDto;
href: string;
onClick?: () => void;
viewCount?: number;
}

export default function StudyCard({ study, href, onClick }: StudyCardProps) {
function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`;

return String(n);
}

export default function StudyCard({
study,
href,
onClick,
viewCount,
}: StudyCardProps) {
const studyType = study.basicInfo?.type as StudyType;
const badgeColor = studyType ? STUDY_TYPE_BADGE_COLORS[studyType] : 'default';
const price = study.basicInfo?.price ?? 0;
const isCompleted = study.basicInfo?.status === 'COMPLETED';

return (
<Link
Expand All @@ -62,14 +75,20 @@
<div className="relative flex h-[180px] items-center justify-center bg-linear-to-br from-[#F87171] to-[#EC4899]">
{study.simpleDetailInfo?.thumbnail?.resizedImages?.[0]
?.resizedImageUrl ? (
<Image
src={
study.simpleDetailInfo.thumbnail.resizedImages[0].resizedImageUrl
}
alt={study.simpleDetailInfo?.title ?? '스터디'}
fill
className="object-cover"
/>
<>
<Image
src={
study.simpleDetailInfo.thumbnail.resizedImages[0]
.resizedImageUrl
}
alt={study.simpleDetailInfo?.title ?? '스터디'}
fill
className={`object-cover ${isCompleted ? 'grayscale' : ''}`}
/>
{isCompleted && (
<div className="rounded-150 absolute inset-0 bg-gray-100 opacity-40" />
)}
</>
) : (
<div className="flex items-center gap-100 text-white">
<span className="text-[14px] font-bold">ZERO ONE IT</span>
Expand Down Expand Up @@ -112,8 +131,9 @@
{study.simpleDetailInfo?.summary}
</p>

{/* 활성 배지 (RECRUITING 일 때만) */}
{study.basicInfo?.status === 'RECRUITING' &&
{/* 활성 배지 (RECRUITING / ENDING_SOON 일 때만) */}
{(study.basicInfo?.status === 'RECRUITING' ||
study.basicInfo?.status === 'ENDING_SOON') &&

Check failure on line 136 in src/components/card/study-card.tsx

View workflow job for this annotation

GitHub Actions / typecheck

This comparison appears to be unintentional because the types '"IN_PROGRESS" | "COMPLETED"' and '"ENDING_SOON"' have no overlap.
(() => {
const remaining =
(study.basicInfo?.maxMembersCount ?? 0) -
Expand Down Expand Up @@ -196,15 +216,25 @@
</div>
</div>

{/* 가격 (0원이면 숨김) */}
{price > 0 && (
<span className="font-designer-24b text-text-strong">
{price.toLocaleString()}
<span className="font-designer-18m text-text-subtlest ml-50">
{/* 가격 & 조회수 */}
<div className="flex items-center gap-200">
{price > 0 && (
<span className="font-designer-24b text-text-strong">
{price.toLocaleString()}
<span className="font-designer-18m text-text-subtlest ml-50">
</span>
</span>
</span>
)}
)}
{viewCount !== undefined && viewCount > 0 && (
<div className="text-text-subtlest flex items-center gap-50">
<Eye width={16} height={16} />
<span className="font-designer-13r">
{formatCount(viewCount)}
</span>
</div>
)}
</div>
</div>
</div>
</Link>
Expand Down
22 changes: 8 additions & 14 deletions src/components/filtering/study-filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/(shadcn)/ui/dropdown-menu';
import ToggleButton from '@/components/ui/toggle/button';
import {
ROLE_OPTIONS_UI,
STUDY_METHOD_LABELS,
} from '@/features/study/group/const/group-study-const';

// 필터 옵션 타입
export interface StudyFilterValues {
Expand All @@ -33,20 +37,10 @@ const STUDY_TYPE_OPTIONS = [
{ value: 'LECTURE_STUDY', label: '강의스터디' },
] as const;

// 포지션 옵션
const POSITION_OPTIONS = [
{ value: 'BACKEND', label: '백엔드' },
{ value: 'FRONTEND', label: '프론트엔드' },
{ value: 'PLANNER', label: '기획자' },
{ value: 'DESIGNER', label: '디자이너' },
] as const;

// 진행 방식 옵션
const METHOD_OPTIONS = [
{ value: 'ONLINE', label: '온라인' },
{ value: 'OFFLINE', label: '오프라인' },
{ value: 'HYBRID', label: '병행' },
] as const;
const METHOD_OPTIONS = Object.entries(STUDY_METHOD_LABELS).map(
([value, label]) => ({ value, label }),
);

// 경험 레벨 옵션
const EXPERIENCE_LEVEL_OPTIONS = [
Expand Down Expand Up @@ -214,7 +208,7 @@ export default function StudyFilter({ values, onChange }: StudyFilterProps) {

<FilterDropdown
label="직무"
options={POSITION_OPTIONS}
options={ROLE_OPTIONS_UI}
selected={values.targetRoles}
onChange={handleTargetRolesChange}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/components/modals/create-mission-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ function CreateMissionForm({
label="수행 가이드"
direction="vertical"
required
counterMax={5000}
maxCharCount={5000}
showCounterRight={false}
>
<TextAreaInput
Expand Down
31 changes: 24 additions & 7 deletions src/components/modals/discretionary-evaluation-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { useUpdateMemberDiscretion } from '@/hooks/queries/group-study-member-ap
import { useToastStore } from '@/stores/use-toast-store';
import { TextAreaInput } from '../ui/input';

export const EVALUATION_COUNT = 3;
const EVALUATION_CONTENT_MAX_LENGTH = 5000;

const DiscretionaryEvaluationFormSchema = z.object({
content: z.string().min(1, '평가 내역을 입력해주세요.'),
});
Expand All @@ -26,7 +29,10 @@ export default function DiscretionaryEvaluationModal({
groupStudyId,
memberId,
}: DiscretionaryEvaluationModalProps) {
const [open, setOpen] = useState<boolean>(false);
const [open, setOpen] = useState(false);
const [hasSubmitted, setHasSubmitted] = useState(false);

const { mutate, isPending } = useUpdateMemberDiscretion();

return (
<Modal.Root open={open} onOpenChange={setOpen}>
Expand All @@ -35,6 +41,7 @@ export default function DiscretionaryEvaluationModal({
size="small"
color="outlined"
className="font-designer-14r w-fit"
disabled={isPending || hasSubmitted}
>
재량평가 추가
</Button>
Expand All @@ -49,7 +56,8 @@ export default function DiscretionaryEvaluationModal({
재량 평가
</Modal.Title>
<p className="font-designer-14r text-text-subtle">
재량 평가는 최대 3회까지 가능하며, 각 평가별 5점씩 부여됩니다.
재량 평가는 최대 {EVALUATION_COUNT}회까지 가능하며, 각 평가별
5점씩 부여됩니다.
</p>
</div>
<Modal.CloseButton onClick={() => setOpen(false)} />
Expand All @@ -59,6 +67,9 @@ export default function DiscretionaryEvaluationModal({
groupStudyId={groupStudyId}
memberId={memberId}
onClose={() => setOpen(false)}
onSubmitSuccess={() => setHasSubmitted(true)}
mutate={mutate}
isPending={isPending}
/>
</Modal.Content>
</Modal.Portal>
Expand All @@ -70,12 +81,18 @@ interface DiscretionaryEvaluationFormProps {
groupStudyId: number;
memberId: number;
onClose: () => void;
onSubmitSuccess: () => void;
mutate: ReturnType<typeof useUpdateMemberDiscretion>['mutate'];
isPending: boolean;
}

function DiscretionaryEvaluationForm({
groupStudyId,
memberId,
onClose,
onSubmitSuccess,
mutate,
isPending,
}: DiscretionaryEvaluationFormProps) {
const methods = useForm<DiscretionaryEvaluationFormValues>({
resolver: zodResolver(DiscretionaryEvaluationFormSchema),
Expand All @@ -87,11 +104,10 @@ function DiscretionaryEvaluationForm({

const { handleSubmit, formState } = methods;

const { mutate: updateMemberDiscretion } = useUpdateMemberDiscretion();
const showToast = useToastStore((state) => state.showToast);

const onValidSubmit = (values: DiscretionaryEvaluationFormValues) => {
updateMemberDiscretion(
mutate(
{
id: groupStudyId,
request: {
Expand All @@ -102,6 +118,7 @@ function DiscretionaryEvaluationForm({
{
onSuccess: () => {
showToast('재량 평가가 성공적으로 제출되었습니다!');
onSubmitSuccess();
onClose();
},
onError: () => {
Expand All @@ -126,15 +143,15 @@ function DiscretionaryEvaluationForm({
name="content"
label="평가 내역"
direction="vertical"
counterMax={500}
maxCharCount={EVALUATION_CONTENT_MAX_LENGTH}
showCounterRight={false}
required
>
<TextAreaInput
id="content"
placeholder="평가 내역을 입력해 주세요."
className="min-h-[300px]"
maxLength={5000}
maxLength={EVALUATION_CONTENT_MAX_LENGTH}
/>
</FormField>
</form>
Expand All @@ -151,7 +168,7 @@ function DiscretionaryEvaluationForm({
size="large"
type="submit"
form="discretionary-evaluation"
disabled={!formState.isValid || formState.isSubmitting}
disabled={!formState.isValid || isPending}
>
평가완료
</Button>
Expand Down
2 changes: 1 addition & 1 deletion src/components/modals/edit-homework-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ function EditHomeworkForm({
label="과제 상세 내용"
direction="vertical"
required
counterMax={5000}
maxCharCount={5000}
showCounterRight={false}
>
<TextAreaInput
Expand Down
2 changes: 1 addition & 1 deletion src/components/modals/edit-mission-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ function EditMissionForm({
label="수행 가이드"
direction="vertical"
required
counterMax={5000}
maxCharCount={5000}
showCounterRight={false}
>
<TextAreaInput
Expand Down
18 changes: 9 additions & 9 deletions src/components/modals/question-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { XIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { axiosInstanceForMultipart } from '@/api/client/axios';
import Button from '@/components/ui/button';
import ImageUploadInput from '@/components/ui/image-upload-input';
import { BaseInput, TextAreaInput } from '@/components/ui/input';
Expand Down Expand Up @@ -67,6 +68,7 @@ export default function QuestionModal({

const handleChangeImage = (file: File | undefined) => {
if (file) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
} else {
Expand All @@ -77,15 +79,13 @@ export default function QuestionModal({
};

const uploadImage = async (uploadUrl: string, file: File) => {
const res = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});

if (!res.ok) {
throw new Error(`이미지 업로드 실패 (status: ${res.status})`);
}
const formData = new FormData();
formData.append('file', file);

const url = new URL(uploadUrl);
const relativePath = url.pathname.replace(/^\/api\/v1\//, '') + url.search;

await axiosInstanceForMultipart.put(relativePath, formData);
Comment on lines +85 to +88
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the file at the specified lines
cat -n src/components/modals/question-modal.tsx | sed -n '80,95p'

Repository: code-zero-to-one/study-platform-client

Length of output: 677


🏁 Script executed:

# Search for uploadUrl/imageUploadUrl type definitions and usage
rg -n "imageUploadUrl|uploadUrl" -C 5 src

Repository: code-zero-to-one/study-platform-client

Length of output: 6949


🏁 Script executed:

# Check for API response types and interfaces
rg -n "type.*Upload|interface.*Upload" -C 3 src

Repository: code-zero-to-one/study-platform-client

Length of output: 1075


🏁 Script executed:

# Search for API documentation or backend specs
fd -e md -e yaml -e json . | xargs grep -l "imageUploadUrl\|uploadUrl" 2>/dev/null | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 135


🏁 Script executed:

# Look for test files that might show expected API responses
fd -e test.ts -e spec.ts -e test.tsx | xargs grep -l "imageUploadUrl\|uploadUrl" 2>/dev/null

Repository: code-zero-to-one/study-platform-client

Length of output: 66


🏁 Script executed:

# Check for any backend configuration or API response examples
rg -n "https://|http://|/api/v1" src/features/study/group/api/question-api.ts -C 10

Repository: code-zero-to-one/study-platform-client

Length of output: 64


🏁 Script executed:

# Check if there are any comments or JSDoc about the URL format
rg -n "imageUploadUrl" src -B 3 -A 3

Repository: code-zero-to-one/study-platform-client

Length of output: 1219


🏁 Script executed:

# Check the comparison code in group-study-form-modal.tsx that uses fetch directly
cat -n src/features/study/group/ui/group-study-form-modal.tsx | sed -n '155,165p'

Repository: code-zero-to-one/study-platform-client

Length of output: 435


🏁 Script executed:

# Search for any axios or fetch usage with uploadUrl/imageUploadUrl
rg -n "axiosInstanceForMultipart\|fetch.*uploadUrl\|fetch.*imageUploadUrl" src -B 2 -A 2

Repository: code-zero-to-one/study-platform-client

Length of output: 64


🏁 Script executed:

# Check if there are environment variables or base URL configs that might indicate URL format
rg -n "BASE_URL|baseURL|NEXT_PUBLIC" src -C 2 | head -30

Repository: code-zero-to-one/study-platform-client

Length of output: 1723


Line 85의 URL 파싱이 상대 경로에서 실패할 수 있습니다.

new URL(uploadUrl)uploadUrl이 상대 경로면 TypeError를 던집니다. 백엔드 API 계약에서 URL 형식(절대/상대)을 명시하지 않으므로, 상대 URL을 반환하는 경우 이미지 업로드가 항상 실패합니다.

수정 제안
-    const url = new URL(uploadUrl);
-    const relativePath = url.pathname.replace(/^\/api\/v1\//, '') + url.search;
+    const parsedUrl = /^https?:\/\//.test(uploadUrl)
+      ? new URL(uploadUrl)
+      : new URL(uploadUrl, window.location.origin);
+    const relativePath =
+      parsedUrl.pathname.replace(/^\/api\/v1\//, '') + parsedUrl.search;

     await axiosInstanceForMultipart.put(relativePath, formData);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/modals/question-modal.tsx` around lines 85 - 88, The code uses
new URL(uploadUrl) which throws for relative paths; update the URL parsing logic
around uploadUrl/relativePath used before calling axiosInstanceForMultipart.put
so it handles both absolute and relative URLs — detect if uploadUrl is absolute
(e.g., starts with http/https) and parse with new URL(uploadUrl) to build
relativePath, otherwise treat uploadUrl as already-relative (or construct a URL
with a base like window.location.origin) and then derive the same
pathname+search string; ensure axiosInstanceForMultipart.put receives the
correct relativePath regardless of uploadUrl format.

};

const resetImageState = () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/modals/submit-homework-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ function SubmitHomeworkForm({
label="과제 상세 내용"
direction="vertical"
required
counterMax={1000}
maxCharCount={1000}
showCounterRight={false}
>
<TextAreaInput
Expand Down
Loading
Loading