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
4 changes: 4 additions & 0 deletions apps/web/public/svgs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import IconPostLikeOutline from "./post-like-outline.svg";
import IconScoreBanner from "./score-banner.svg";
import IconSearchBanner from "./search-banner.svg";
import IconSearchFilled from "./search-filled.svg";
import IconShare from "./shareIcon.svg";
import IconShareFilled from "./shareIconFilled.svg";
import IconSignupRegionAmerica from "./signup-region-america.svg";
import IconSignupRegionAsia from "./signup-region-asia.svg";
import IconSignupRegionEurope from "./signup-region-europe.svg";
Expand Down Expand Up @@ -42,6 +44,8 @@ export {
IconScoreBanner,
IconSearchBanner,
IconSearchFilled,
IconShare,
IconShareFilled,
IconSignupRegionAmerica,
IconSignupRegionAsia,
IconSignupRegionEurope,
Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/svgs/shareIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions apps/web/public/svgs/shareIconFilled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions apps/web/src/apis/news/getNewsList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { type ArticleListResponse, NewsQueryKeys, newsApi } from "./api";
/**
* @description 아티클 목록 조회 훅
*/
const useGetArticleList = (userId: number) => {
const useGetArticleList = (userId: number, options?: { enabled?: boolean }) => {
return useQuery<ArticleListResponse, AxiosError, Article[]>({
queryKey: [NewsQueryKeys.articleList, userId],
queryFn: () => {
Expand All @@ -17,7 +17,7 @@ const useGetArticleList = (userId: number) => {
return newsApi.getArticleList(userId);
},
staleTime: 1000 * 60 * 10, // 10분
enabled: userId !== null && userId !== 0,
enabled: userId !== null && userId !== 0 && (options?.enabled ?? true),
select: (data) => data.newsResponseList,
});
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useDeleteWish, useGetWishList, usePostAddWish } from "@/apis/universities";
import useAuthStore from "@/lib/zustand/useAuthStore";
import { IconShare, IconShareFilled } from "@/public/svgs";

const likeIcon = (
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="16" viewBox="0 0 19 16" fill="none">
Expand All @@ -24,25 +25,14 @@ const likeIconFilled = (
</svg>
);

const copyIcon = (
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="19" viewBox="0 0 17 19" fill="none">
<path
d="M8.5 2.13333V11.7667M11.7143 4.4L8.5 1L5.28571 4.4M1 10.0667V15.7333C1 16.3345 1.22576 16.911 1.62763 17.3361C2.02949 17.7612 2.57454 18 3.14286 18H13.8571C14.4255 18 14.9705 17.7612 15.3724 17.3361C15.7742 16.911 16 16.3345 16 15.7333V10.0667"
stroke="#4672EE"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);

interface UniversityBtnsProps {
universityId: number;
}
const UniversityBtns = ({ universityId }: UniversityBtnsProps) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);

const [isLiked, setIsLiked] = useState(false);
const [isShareActive, setIsShareActive] = useState(false);
const { data: favoriteUniv } = useGetWishList(isAuthenticated);
const { mutate: postUniversityFavorite } = usePostAddWish();
const { mutate: deleteUniversityFavorite } = useDeleteWish();
Expand All @@ -64,6 +54,8 @@ const UniversityBtns = ({ universityId }: UniversityBtnsProps) => {
const handleCopy = () => {
navigator.clipboard.writeText(window.location.href).then(() => {});
toast.success("URL이 복사되었습니다.");
setIsShareActive(true);
setTimeout(() => setIsShareActive(false), 600);
};
Comment on lines 54 to 59
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

공유 기능 구현에서 두 가지 개선이 필요합니다.

다음 사항들을 확인해 주세요:

  1. 클립보드 API 에러 처리 누락 (Line 55)

    • navigator.clipboard.writeText()의 빈 .then() 콜백이 불필요하며, 실패 시 처리를 위한 .catch() 핸들러가 없습니다.
    • 클립보드 API는 권한 문제나 브라우저 제약으로 실패할 수 있습니다.
  2. 메모리 누수 가능성 (Lines 57-58)

    • setTimeout이 정리(cleanup)되지 않아, 600ms 이내에 컴포넌트가 언마운트되면 언마운트된 컴포넌트에 상태 업데이트를 시도하게 됩니다.
    • React에서 흔히 발생하는 안티패턴입니다.
🔧 개선된 구현 제안
-  const handleCopy = () => {
-    navigator.clipboard.writeText(window.location.href).then(() => {});
-    toast.success("URL이 복사되었습니다.");
-    setIsShareActive(true);
-    setTimeout(() => setIsShareActive(false), 600);
-  };
+  const handleCopy = () => {
+    navigator.clipboard
+      .writeText(window.location.href)
+      .then(() => {
+        toast.success("URL이 복사되었습니다.");
+        setIsShareActive(true);
+      })
+      .catch(() => {
+        toast.error("URL 복사에 실패했습니다.");
+      });
+  };
+
+  useEffect(() => {
+    if (!isShareActive) return;
+    const timer = setTimeout(() => setIsShareActive(false), 600);
+    return () => clearTimeout(timer);
+  }, [isShareActive]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/app/university/`[homeUniversity]/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx
around lines 54 - 59, handleCopy currently calls
navigator.clipboard.writeText(...).then(() => {}) without error handling and
creates a potential memory leak by using setTimeout without cleanup; update
handleCopy to await or use .then/.catch on navigator.clipboard.writeText to show
a success toast on resolve and an error toast on reject (include clear
messaging), and replace the raw setTimeout with a tracked timeoutId (e.g., store
id in a ref or state) so you can call clearTimeout during unmount; add a
useEffect cleanup that clears the timeoutId and prevents calling
setIsShareActive on an unmounted component.

return (
<>
Expand All @@ -86,10 +78,20 @@ const UniversityBtns = ({ universityId }: UniversityBtnsProps) => {
{isLiked ? likeIconFilled : likeIcon}
</button>
<button
type="button"
onClick={handleCopy}
className={`/* stroke: #FFF; stroke-width: 1px; */ /* fill: linear-gradient(...) */ /* CSS의 fill은 SVG 속성이지만, 버튼 배경으로 적용합니다. */ /* backdrop-filter: blur(2px); */ /* filter: drop-shadow(...) */ /* 기타 스타일 */ rounded-full border border-white/80 bg-[linear-gradient(136deg,rgba(255,255,255,0.4)_14.87%,rgba(199,212,250,0.8)_89.1%)] p-3 drop-shadow-[2px_2px_6px_#C7D4FA] backdrop-blur-[2px] transition-transform duration-200 ease-in-out hover:scale-110 focus:outline-none focus:ring-2 focus:ring-white/50 active:scale-95`}
className="relative h-10 w-10 transition-transform duration-200 ease-in-out hover:scale-110 focus:outline-none active:scale-95"
>
{copyIcon}
<IconShare
width={40}
height={40}
className={`absolute inset-0 transition-opacity duration-300 ease-in-out ${isShareActive ? "opacity-0" : "opacity-100"}`}
/>
<IconShareFilled
width={40}
height={40}
className={`absolute inset-0 transition-opacity duration-300 ease-in-out ${isShareActive ? "opacity-100" : "opacity-0"}`}
/>
</button>
Comment on lines 80 to 95
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

공유 버튼의 키보드 접근성 개선이 필요합니다.

아이콘 전환 애니메이션 구현은 깔끔하지만, 버튼에 포커스 인디케이터가 없어 키보드 네비게이션 사용자가 현재 포커스 위치를 파악하기 어렵습니다.

위의 좋아요 버튼(Line 76)에는 focus:ring-2 focus:ring-white/50이 있지만, 공유 버튼에는 focus:outline-none만 있고 대체 포커스 스타일이 없습니다.

♿ 접근성 개선 제안
       <button
         type="button"
         onClick={handleCopy}
-        className="relative h-10 w-10 transition-transform duration-200 ease-in-out hover:scale-110 focus:outline-none active:scale-95"
+        className="relative h-10 w-10 rounded-full transition-transform duration-200 ease-in-out hover:scale-110 focus:outline-none focus:ring-2 focus:ring-white/50 active:scale-95"
       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
onClick={handleCopy}
className={`/* stroke: #FFF; stroke-width: 1px; */ /* fill: linear-gradient(...) */ /* CSS의 fill은 SVG 속성이지만, 버튼 배경으로 적용합니다. */ /* backdrop-filter: blur(2px); */ /* filter: drop-shadow(...) */ /* 기타 스타일 */ rounded-full border border-white/80 bg-[linear-gradient(136deg,rgba(255,255,255,0.4)_14.87%,rgba(199,212,250,0.8)_89.1%)] p-3 drop-shadow-[2px_2px_6px_#C7D4FA] backdrop-blur-[2px] transition-transform duration-200 ease-in-out hover:scale-110 focus:outline-none focus:ring-2 focus:ring-white/50 active:scale-95`}
className="relative h-10 w-10 transition-transform duration-200 ease-in-out hover:scale-110 focus:outline-none active:scale-95"
>
{copyIcon}
<IconShare
width={40}
height={40}
className={`absolute inset-0 transition-opacity duration-300 ease-in-out ${isShareActive ? "opacity-0" : "opacity-100"}`}
/>
<IconShareFilled
width={40}
height={40}
className={`absolute inset-0 transition-opacity duration-300 ease-in-out ${isShareActive ? "opacity-100" : "opacity-0"}`}
/>
</button>
<button
type="button"
onClick={handleCopy}
className="relative h-10 w-10 rounded-full transition-transform duration-200 ease-in-out hover:scale-110 focus:outline-none focus:ring-2 focus:ring-white/50 active:scale-95"
>
<IconShare
width={40}
height={40}
className={`absolute inset-0 transition-opacity duration-300 ease-in-out ${isShareActive ? "opacity-0" : "opacity-100"}`}
/>
<IconShareFilled
width={40}
height={40}
className={`absolute inset-0 transition-opacity duration-300 ease-in-out ${isShareActive ? "opacity-100" : "opacity-0"}`}
/>
</button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/app/university/`[homeUniversity]/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx
around lines 80 - 95, The share button lacks a visible keyboard focus indicator;
update the button element that uses onClick={handleCopy} and the
isShareActive/IconShare/IconShareFilled visuals to include the same focus ring
classes used by the like button (e.g., add focus:ring-2 focus:ring-white/50 and
remove or replace focus:outline-none) so keyboard users see a clear focus state
while preserving the current hover/active animations and icon opacity toggles.

</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Image from "@/components/ui/FallbackImage";
import type { Article } from "@/types/news";
import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl";

interface ArticlePreviewProps {
article: Article;
}

const ArticlePreview = ({ article }: ArticlePreviewProps) => {
const thumbnailUrl = normalizeImageUrlToUploadCdn(article.thumbnailUrl);
return (
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-lg bg-k-50 transition-opacity hover:opacity-90"
>
<div className="relative h-32 w-full bg-gradient-to-br from-blue-400 to-blue-600">
<Image src={thumbnailUrl} alt={article.title} fill className="object-cover" />
</div>
<p className="line-clamp-1 px-3 py-2 text-k-800 typo-sb-7">{article.title}</p>
</a>
);
};

export default ArticlePreview;
Loading