Skip to content
Merged
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
46 changes: 30 additions & 16 deletions src/apis/club/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export interface ClubDetailResponse {

interface Recruitment {
status: 'BEFORE' | 'ONGOING' | 'CLOSED';
startDate?: string;
endDate?: string;
startAt?: string;
endAt?: string;
}

export type PositionType = 'PRESIDENT' | 'VICE_PRESIDENT' | 'MANAGER' | 'MEMBER';
Expand Down Expand Up @@ -114,8 +114,8 @@ export interface ClubRecruitment {
id: number;
clubId: number;
status: 'BEFORE' | 'ONGOING' | 'CLOSED';
startDate: string;
endDate: string;
startAt: string;
endAt: string;
content: string;
images: ClubRecruitmentImage[];
isFeeRequired: boolean;
Expand All @@ -137,13 +137,21 @@ export interface AppliedClubResponse {
//========================== Club Manager Entities =========================//
interface Application {
id: number;
studentNumber: number;
studentNumber: string;
name: string;
imageUrl: string;
appliedAt: string;
feePaymentImageUrl?: string;
}
Comment thread
ff1451 marked this conversation as resolved.

export interface ClubApplicationsResponse {
export interface ClubApplicationsParams {
page?: number;
limit?: number;
sortBy?: 'APPLIED_AT' | 'STUDENT_NUMBER' | 'NAME';
sortDirection?: 'ASC' | 'DESC';
}

export interface ClubApplicationsResponse extends PaginationResponse {
applications: Application[];
}

Expand All @@ -156,7 +164,7 @@ interface ApplicationAnswer {

export interface ClubApplicationDetailResponse {
applicationId: number;
studentNumber: number;
studentNumber: string;
name: string;
imageUrl: string;
appliedAt: string;
Expand All @@ -176,13 +184,13 @@ type BaseRecruitment = {
export type ClubRecruitmentRequest =
| (BaseRecruitment & {
isAlwaysRecruiting: true;
startDate?: never;
endDate?: never;
startAt?: never;
endAt?: never;
})
| (BaseRecruitment & {
isAlwaysRecruiting: false;
startDate: string;
endDate: string;
startAt: string;
endAt: string;
});

export interface ClubQuestionRequest {
Expand Down Expand Up @@ -289,11 +297,17 @@ export interface PreMemberDeleteRequest {

//========================== Club Settings Entities =========================//

interface ClubSettingsRecruitment {
startDate: string;
endDate: string;
isAlwaysRecruiting: boolean;
}
type ClubSettingsRecruitment =
| {
isAlwaysRecruiting: true;
startAt?: never;
endAt?: never;
}
| {
isAlwaysRecruiting: false;
startAt: string;
endAt: string;
};

interface ClubSettingsApplication {
questionCount: number;
Expand Down
13 changes: 9 additions & 4 deletions src/apis/club/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type PreMembersList,
type ClubSettingsResponse,
type ClubSettingsPatchRequest,
type ClubApplicationsParams,
} from './entity';
Comment thread
ff1451 marked this conversation as resolved.

export const getClubs = async (params: ClubRequestParams) => {
Expand Down Expand Up @@ -89,10 +90,14 @@ export const getManagedClubs = async () => {
return response;
};

export const getManagedClubApplications = async (clubId: number) => {
const response = await apiClient.get<ClubApplicationsResponse>(`clubs/${clubId}/applications`, {
requiresAuth: true,
});
export const getManagedClubApplications = async (clubId: number, params?: ClubApplicationsParams) => {
const response = await apiClient.get<ClubApplicationsResponse, ClubApplicationsParams>(
`clubs/${clubId}/applications`,
{
params,
requiresAuth: true,
}
);
return response;
};

Expand Down
157 changes: 157 additions & 0 deletions src/components/common/LinkifiedText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Fragment, useMemo } from 'react';
import { cn } from '@/utils/ts/cn';

const URL_REGEX = /(?:https?:\/\/|www\.)[^\s]+/gi;
const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,!?;:'"`]+$/;
const INSTAGRAM_HANDLE_REGEX =
/(^|[^A-Za-z0-9._])(@[A-Za-z0-9_](?:[A-Za-z0-9._]{0,28}[A-Za-z0-9_])?)(?=$|[^A-Za-z0-9._])/g;

type LinkPart =
| {
type: 'text';
value: string;
}
| {
type: 'link';
value: string;
href: string;
};

interface LinkifiedTextProps {
text: string;
className?: string;
linkClassName?: string;
}

const normalizeHref = (url: string) => {
if (/^https?:\/\//i.test(url)) {
return url;
}

return `https://${url}`;
};

const splitTrailingPunctuation = (value: string) => {
const trailing = value.match(TRAILING_PUNCTUATION_REGEX)?.[0] ?? '';

if (!trailing) {
return { link: value, trailing: '' };
}

return {
link: value.slice(0, -trailing.length),
trailing,
};
};

const parseLinkParts = (text: string): LinkPart[] => {
const parts: LinkPart[] = [];
const matcher = new RegExp(URL_REGEX);
let lastIndex = 0;
let match = matcher.exec(text);

while (match) {
const matchedText = match[0];
const startIndex = match.index;
const endIndex = startIndex + matchedText.length;

if (startIndex > lastIndex) {
parts.push({ type: 'text', value: text.slice(lastIndex, startIndex) });
}

const { link, trailing } = splitTrailingPunctuation(matchedText);

if (link) {
parts.push({ type: 'link', value: link, href: normalizeHref(link) });
}

if (trailing) {
parts.push({ type: 'text', value: trailing });
}

lastIndex = endIndex;
match = matcher.exec(text);
}

if (lastIndex < text.length) {
parts.push({ type: 'text', value: text.slice(lastIndex) });
}

return parts;
};

const parseInstagramParts = (text: string): LinkPart[] => {
const parts: LinkPart[] = [];
const matcher = new RegExp(INSTAGRAM_HANDLE_REGEX);
let lastIndex = 0;
let match = matcher.exec(text);

while (match) {
const prefix = match[1] ?? '';
const handle = match[2];
const startIndex = match.index;
const handleStartIndex = startIndex + prefix.length;
const handleEndIndex = handleStartIndex + handle.length;

if (startIndex > lastIndex) {
parts.push({ type: 'text', value: text.slice(lastIndex, startIndex) });
}

if (prefix) {
parts.push({ type: 'text', value: prefix });
}

parts.push({
type: 'link',
value: handle,
href: `https://instagram.com/${handle.slice(1)}`,
});

lastIndex = handleEndIndex;
match = matcher.exec(text);
}

if (lastIndex < text.length) {
parts.push({ type: 'text', value: text.slice(lastIndex) });
}

return parts;
};

function LinkifiedText({ text, className, linkClassName }: LinkifiedTextProps) {
const parts = useMemo(() => {
return parseLinkParts(text).flatMap((part) => {
if (part.type === 'link') {
return [part];
}

return parseInstagramParts(part.value);
});
}, [text]);

const content = parts.map((part, index) => {
if (part.type === 'text') {
return <Fragment key={`text-${index}`}>{part.value}</Fragment>;
}

return (
<a
key={`link-${index}`}
href={part.href}
target="_blank"
rel="noopener noreferrer"
className={cn('text-primary break-all underline', linkClassName)}
>
{part.value}
</a>
);
});

if (className) {
return <span className={className}>{content}</span>;
}

return <>{content}</>;
}

export default LinkifiedText;
5 changes: 3 additions & 2 deletions src/pages/Chat/ChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import clsx from 'clsx';
import { useParams } from 'react-router-dom';
import PaperPlaneIcon from '@/assets/svg/paper-plane.svg';
import LinkifiedText from '@/components/common/LinkifiedText';
import useKeyboardHeight from '@/utils/hooks/useViewportHeight';
import useChat from './hooks/useChat';
import useChatRoomScroll from './hooks/useChatRoomScroll';
Expand Down Expand Up @@ -117,7 +118,7 @@ function ChatRoom() {
)}

<div className="rounded-lg bg-[#f1f8ff] px-3 py-2 text-sm wrap-anywhere whitespace-pre-wrap">
{message.content}
<LinkifiedText text={message.content} />
</div>
</div>

Expand All @@ -134,7 +135,7 @@ function ChatRoom() {
{message.isMine && (
<div className="flex max-w-full min-w-0 flex-row-reverse items-end gap-2">
<div className="rounded-lg bg-[#f5f5f5] px-3 py-2 text-sm wrap-anywhere whitespace-pre-wrap">
{message.content}
<LinkifiedText text={message.content} />
</div>

<div className="flex shrink-0 flex-col items-end self-end">
Expand Down
8 changes: 5 additions & 3 deletions src/pages/Club/ClubDetail/components/ClubRecruitment.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Link, useNavigate } from 'react-router-dom';
import BottomModal from '@/components/common/BottomModal';
import Card from '@/components/common/Card';
import LinkifiedText from '@/components/common/LinkifiedText';
import { useClubApplicationStore } from '@/stores/clubApplicationStore';
import useBooleanState from '@/utils/hooks/useBooleanState';
import useClubApply from '../../Application/hooks/useClubApply';
Expand All @@ -20,6 +21,7 @@ function ClubRecruitment({ clubId, isMember }: ClubRecruitProps) {
const setApplication = useClubApplicationStore((s) => s.setApplication);
const isRecruitmentOpen = clubRecruitment.status === 'ONGOING';
const canApply = isRecruitmentOpen && !clubRecruitment.isApplied && !isMember;
const recruitmentContent = clubRecruitment.content.replace(/\\n/g, '\n');

const handleApply = () => {
if (isFeeRequired) {
Expand All @@ -44,8 +46,8 @@ function ClubRecruitment({ clubId, isMember }: ClubRecruitProps) {
<div className="text-sm leading-4 font-bold text-indigo-700">신입 회원 모집</div>
<div className="mt-1.5 text-xs leading-3.5 text-indigo-300">
모집 기간 :{' '}
{clubRecruitment.startDate && clubRecruitment.endDate
? `${clubRecruitment.startDate} ~ ${clubRecruitment.endDate}`
{clubRecruitment.startAt && clubRecruitment.endAt
? `${clubRecruitment.startAt} ~ ${clubRecruitment.endAt}`
: '상시 모집'}
</div>
</div>
Expand Down Expand Up @@ -78,7 +80,7 @@ function ClubRecruitment({ clubId, isMember }: ClubRecruitProps) {
<Card>
<div className="text-sm leading-4 font-bold text-indigo-700">모집 공고</div>
<div className="text-xs leading-3.5 whitespace-pre-wrap text-indigo-300">
{clubRecruitment.content.replace(/\\n/g, '\n')}
<LinkifiedText text={recruitmentContent} />
</div>
{clubRecruitment.images.length > 0 && (
<div className="mt-2 flex flex-col gap-2">
Expand Down
3 changes: 2 additions & 1 deletion src/pages/Club/ClubList/components/ClubCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ interface ClubCardProps {
}

function getDDay(dateString: string): string {
const [year, month, day] = dateString.split('.').map(Number);
const datePart = dateString.split('T')[0].split(' ')[0].replace(/-/g, '.');
const [year, month, day] = datePart.split('.').map(Number);
const targetDate = new Date(year, month - 1, day);
const today = new Date();

Expand Down
Loading