Skip to content
Merged
15 changes: 10 additions & 5 deletions client/src/features/main/domains/DomainCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,23 @@ type Props = {
};

export function DomainCard({ icon, title, desc, onClick, disabled }: Props) {
const isDisabled = !!disabled;
const buttonLabel = isDisabled ? "설정 완료" : "설정하기";

return (
<div className={s.card}>
<div className={`${s.card} ${isDisabled ? s.disabled : ""}`}>
<div className={s.icon}>{icon}</div>
<h3 className={s.title}>{title}</h3>
<p className={s.desc}>{desc}</p>

<button
className={`${s.button} btn-outline`}
disabled={disabled} // 비활성화
onClick={disabled ? undefined : onClick} // 클릭 완전 차단
className={`${s.button} btn-outline ${
isDisabled ? s.buttonDisabled : ""
}`}
disabled={isDisabled}
onClick={isDisabled ? undefined : onClick}
>
설정하기
{buttonLabel}
</button>
</div>
);
Expand Down
19 changes: 18 additions & 1 deletion client/src/features/main/domains/domains.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,21 @@
}



.buttonDisabled {
color: #757373;
border-color: #d0d3d8;
cursor: default;
}

.buttonDisabled:hover {
color: #757373;
border-color: #d0d3d8;
}

.card.disabled,
.card.disabled:hover {
transform: none !important;
box-shadow: 0 10px 30px rgba(2, 6, 23, .06);
background: #ffffff !important;
cursor: default !important;
}
53 changes: 41 additions & 12 deletions client/src/features/notice/NoticeSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,15 +273,22 @@ function NoticeCard({
// 편집 중이거나 버튼/인풋 클릭 시에는 선택 동작 안함
if (editing) return;
const target = e.target as HTMLElement;
if (target.closest("button") || target.closest("input") || target.closest("a")) return;
if (
target.closest("button") ||
target.closest("input") ||
target.closest("a")
)
return;

// 이미 선택된 카드면 선택 해제, 아니면 선택
onSelect(isSelected ? null : setting.domain_id);
};

return (
<div
className={`notice-card ${editing ? "notice-card--editing" : ""} ${isSelected ? "notice-card--selected" : ""}`}
className={`notice-card ${editing ? "notice-card--editing" : ""} ${
isSelected ? "notice-card--selected" : ""
}`}
onClick={handleCardClick}
style={{ cursor: editing ? "default" : "pointer" }}
>
Expand Down Expand Up @@ -437,7 +444,7 @@ function NoticeCard({
</>
) : (
<>
{/* <div className="notice-contour"></div> */}
<div className="notice-contour"></div>

{/* 다중 배지 표시 */}
<div className="notice-card-channel body3">
Expand All @@ -453,20 +460,33 @@ function NoticeCard({
)}
</div>

{/* 요약 상태 토글 (읽기 전용) */}
{/* 요약 상태 토글 (바로 수정 가능) */}
<div className="notice-card-channel body3">
<div className="notify-toggle-wrap flex-row">
<span className="notify-label body3">요약</span>
<span className="notify-label" aria-hidden>
{summary ? "ON" : "OFF"}
</span>
<div
className={`toggle-wrap ${
item.summary ? "on" : "off"
} readonly`}
aria-label={`요약 ${item.summary ? "ON" : "OFF"}`}
role="switch"
aria-checked={summary}
className={`toggle-wrap ${summary ? "on" : "off"}`}
aria-label={`요약 ${summary ? "끄기" : "켜기"}`}
onClick={async (e) => {
e.stopPropagation(); // 카드 선택 방지
const newSummary = !summary;
setSummary(newSummary);
try {
await updateSetting(getId(setting), { summary: newSummary });
onUpdated({ ...setting, summary: newSummary });
} catch {
setSummary(!newSummary); // 실패 시 롤백
show("요약 설정 변경에 실패했습니다.");
}
}}
style={{ cursor: "pointer" }}
>
<Toggle defaultChecked={item.summary} />
<Toggle key={summary ? "1" : "0"} defaultChecked={summary} />
</div>
</div>
</div>
Expand All @@ -490,7 +510,10 @@ interface NoticeSettingProps {
onSelectDomain: (domainId: string | null) => void;
}

function NoticeSettingInner({ selectedDomainId, onSelectDomain }: NoticeSettingProps) {
function NoticeSettingInner({
selectedDomainId,
onSelectDomain,
}: NoticeSettingProps) {
const [items, setItems] = useState<NoticeItem[]>([]);
const [settingsMap, setSettingsMap] = useState<Record<string, Setting>>({});
const [domainMap, setDomainMap] = useState<Record<string, string>>({});
Expand Down Expand Up @@ -613,9 +636,15 @@ function NoticeSettingInner({ selectedDomainId, onSelectDomain }: NoticeSettingP
);
}

const NoticeSetting: React.FC<NoticeSettingProps> = ({ selectedDomainId, onSelectDomain }) => (
const NoticeSetting: React.FC<NoticeSettingProps> = ({
selectedDomainId,
onSelectDomain,
}) => (
<ToastProvider>
<NoticeSettingInner selectedDomainId={selectedDomainId} onSelectDomain={onSelectDomain} />
<NoticeSettingInner
selectedDomainId={selectedDomainId}
onSelectDomain={onSelectDomain}
/>
</ToastProvider>
);

Expand Down
58 changes: 36 additions & 22 deletions client/src/features/notice/RecentNotice.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// src/features/notice/RecentNotice.tsx
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "./RecentNotice.scss";
import "../../../public/assets/style/_flex.scss";
import "../../../public/assets/style/_typography.scss";
Expand Down Expand Up @@ -27,6 +27,7 @@ type RecentItem = {

const INITIAL_COUNT = 10;
const PAGE_SIZE = 5;
const POLLING_INTERVAL = 500; // 0.5초마다 새 알림 확인

const pad2 = (n: number) => (n < 10 ? `0${n}` : String(n));

Expand Down Expand Up @@ -113,42 +114,55 @@ const RecentNotice: React.FC<RecentNoticeProps> = ({ selectedDomainId }) => {
const [allItems, setAllItems] = useState<RecentItem[]>([]);
const [visibleCount, setVisibleCount] = useState(INITIAL_COUNT);

// 초기 로드: settings + domains
useEffect(() => {
let alive = true;
(async () => {
try {
// 데이터 로드 함수
const loadData = useCallback(async (isInitial = false) => {
try {
if (isInitial) {
setLoading(true);
setBanner(null);
}

const [settings, domainList] = await Promise.all([
fetchSettings(),
fetchMain(),
]);

const [settings, domainList] = await Promise.all([
fetchSettings(),
fetchMain(),
]);
if (!alive) return;
const flat = flattenAndSortMessages(settings);
setAllItems(flat);
setDomains(domainList);

const flat = flattenAndSortMessages(settings);
setAllItems(flat);
setDomains(domainList);
if (isInitial) {
setVisibleCount(Math.min(INITIAL_COUNT, flat.length));
} catch (e: any) {
if (!alive) return;
}
} catch (e: any) {
if (isInitial) {
setBanner({
type: "error",
text: e?.message || "최근 알림을 불러오지 못했습니다.",
});
setAllItems([]);
setDomains([]);
setVisibleCount(0);
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
} finally {
if (isInitial) setLoading(false);
}
}, []);

// 초기 로드
useEffect(() => {
loadData(true);
}, [loadData]);

// 주기적 polling (30초마다)
useEffect(() => {
const interval = setInterval(() => {
loadData(false);
}, POLLING_INTERVAL);

return () => clearInterval(interval);
}, [loadData]);

// 선택된 도메인으로 필터링
const filteredItems = useMemo(() => {
if (!selectedDomainId) return allItems;
Expand Down
2 changes: 1 addition & 1 deletion server/src/crawl/webCrawler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class WebCrawler {
case 'pknu':
crawler = new PKNUCrawler();
break;
case 'pknu-notice-watch':
case 'preview--pknu-notice-watch':
console.log(`[WebCrawler] TestCrawler 사용: ${url}`);
crawler = new TestCrawler(url);
break;
Expand Down
2 changes: 1 addition & 1 deletion server/src/data/initialDomains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const initialDomains: InitialDomainData[] = [
_id: "4",
name: "테스트",
url_list: [
"https://pknu-notice-watch.lovable.app",
"https://preview--pknu-notice-watch.lovable.app",
],
keywords: ["테스트"],
setting_ids: [],
Expand Down
Loading