Files
2026-06-11 18:19:26 +09:00

552 lines
40 KiB
React
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable */
// ─────────────────────────────────────────────────────────────
// searchgg · 커뮤니티 (목록 화면)
// 게시판: 자유 / 공략·팁 / 질문 / 파티 모집 — 텍스트 리스트형
// 사이드: 인기글(HOT) · 주간 베스트
// ─────────────────────────────────────────────────────────────
const CMPT = '#48515f';
const CMCH = "'Chakra Petch',sans-serif";
// ── 게시글 더미 데이터 (시드 고정) ──
const CM_BOARDS = ['자유', '공략·팁', '질문', '파티 모집'];
const CM_POSTS = (() => {
const titles = {
'자유': ['오늘 제3보급 진짜 미친 매치 했다', '시즌2 보상 다들 받았음?', '요즘 저녁 큐 잡히는 속도 어떰', '복귀했는데 감도 설정 다 까먹음', '클랜전 하다가 현타온 썰', '이번 패치 평가 어떻게 생각함', '10년차 유저인데 아직도 재밌네', '오랜만에 5연승 함 ㅋㅋ', '새벽 매치 분위기 좋아서 글 남김', '내일 점검이라는데 뭐 바뀜?', '신캐 나오면 누구 뽑을 거임', '오늘자 명장면.gif 올림'],
'공략·팁': ['데드폴 수비 루트 총정리 (시즌2 기준)', '웨어하우스 러시 타이밍 분석', '저감도 vs 고감도 — 결론 정리해줌', '라이플 반동 제어 연습법 공유', '컨테이너시티 스나 포지션 5곳', '클랜 랭크전 픽 우선순위 가이드', '초보가 제일 많이 하는 실수 7가지', '3vs3 맵별 스폰 위치 정리', '에임 워밍업 루틴 추천', '파티 랭크전 콜 사인 정리본'],
'질문': ['랭크 점수 산정 방식 아시는 분?', '티어 강등 기준이 어떻게 되나요', '클랜 옮기면 클랜전 기록 초기화되나요?', '모니터 144hz 체감 큰가요', '복귀 유저인데 뭐부터 하면 됨?', '계급이랑 티어 차이가 뭔가요', '신고하면 처리 결과 알 수 있나요', '파티 랭크전 듀오 가능한가요', '토너먼트 참가 조건 아시는 분', '전적 갱신이 안 되는데 저만 그런가요'],
'파티 모집': ['파티 랭크전 같이 하실 분 (골드 이상)', '저녁 9시 클랜전 용병 1명 구해요', '3보급 내전 인원 모집합니다', '주말 토너먼트 팀원 구함 (스나 1)', '심야 듀오 구합니다 — 마스터 부근', '즐겜 파티 상시 모집 중', '음성 가능한 파티 멤버 2명', 'A보급 연습 같이 하실 분', '평일 오후 파티 고정 멤버 구해요', '랭커 도전 듀오 모집 (진지하게)'],
};
const nicks = ['독수리오형제', '한밤의저격수', '돌격대장', '연사의신', '클러치킹', '광속에임', '벽뚫는소리', '플래시뱅', '노스코프', '리코일제로', '소음기장착', '검은표범', '강철멘탈', '야시경', '엄폐엄폐', '침착한손', '스나는예술', '원피스원킬'];
const out = [];
let id = 0;
CM_BOARDS.forEach((board, bi) => {
titles[board].forEach((title, ti) => {
const seed = (bi * 131 + ti * 17 + title.length * 7) % 9973;
const hours = 1 + (seed % 70);
out.push({
id: ++id, board, title,
author: nicks[(seed + bi) % nicks.length],
time: hours < 24 ? `${hours}시간 전` : `${Math.floor(hours / 24)}일 전`,
hoursAgo: hours,
views: 120 + (seed % 4200),
cmt: seed % 46,
up: seed % 88,
hot: seed % 5 === 0,
hasImage: seed % 3 === 0,
party: board === '파티 모집' ? {
ptype: ['클랜전', '파티 랭크전', '클랜 랭크전', '토너먼트', '생존', '기타'][seed % 6],
voice: seed % 3 !== 0,
} : null,
});
});
});
return out.sort((a, b) => a.hoursAgo - b.hoursAgo);
})();
// ── 고정 공지 ──
const CM_NOTICES = [
{ title: '커뮤니티 이용 규칙 안내 — 비방·도배·정치 글 제재', time: '운영팀' },
{ title: '파티 모집 글은 파티 모집 게시판에만 작성해주세요', time: '운영팀' },
];
// 파티 모집 유형
const CM_PARTY_TYPES = ['클랜전', '파티 랭크전', '클랜 랭크전', '토너먼트', '생존', '기타'];
// 파티 모집 조건 → 배지 라벨 배열
function cmPartyBadges(party) {
return [party.ptype, party.voice ? '보이스 채팅 필수' : null].filter(Boolean);
}
// 개념글 기준 추천 수
const CM_BEST_UP = 40;
// 서든어택 계정 인증 여부 (닉네임 시드 고정)
function cmVerified(name) {
if (!name) return false;
if (window.SA && window.SA.me && name === window.SA.me.name) return true;
let s = 0;
for (let i = 0; i < name.length; i++) s = (s + name.charCodeAt(i) * (i + 1)) % 997;
return s % 3 !== 0; // 약 2/3 인증
}
// 인증 배지 (인증=서든 마크 / 미인증=표시 없음)
function CmVerifiedTag({ name, size = 13 }) {
if (!cmVerified(name)) return null;
return (
<span title="서든어택 계정 인증 유저" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: size, height: size, borderRadius: '50%', background: CMPT, flexShrink: 0 }}>
<svg width={size * 0.68} height={size * 0.68} viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
</span>
);
}
function CmAuthor({ name, style }) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
<span style={{ ...style, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
<CmVerifiedTag name={name} />
</span>
);
}
// ── 글 상세 더미 (본문·댓글, 시드 고정) ──
const CM_BODIES = {
'자유': '오늘 매치 돌리면서 느낀 점 적어봅니다.\n\n요즘 큐 잡히는 속도도 그렇고 전반적인 매치 퀄리티가 시즌 초반보다 확실히 좋아진 느낌이에요. 다들 체감 어떠신지 궁금합니다.\n\n댓글로 의견 남겨주세요!', '공략·팁': '직접 테스트해보고 정리한 내용입니다.\n\n1. 진입 타이밍은 소리 정보가 제일 중요합니다. 발소리 출력을 충분히 키우세요.\n2. 교전 전에 미니맵으로 아군 위치를 확인하는 습관을 들이면 생존율이 크게 올라갑니다.\n3. 마지막으로, 장시간 연승보다 짧게 끊어서 집중하는 게 에임 유지에 좋았습니다.\n\n궁금한 점은 댓글로 물어보세요.', '질문': '검색해봐도 정확한 내용이 안 나와서 질문 남깁니다.\n\n아시는 분 계시면 답변 부탁드려요. 미리 감사합니다!', '파티 모집': '안녕하세요, 글 제목 그대로 함께하실 분 찾습니다.\n\n· 매너 좋으신 분이면 실력 무관\n· 저녁 시간대 위주로 활동합니다\n\n댓글이나 쪽지 주세요!',
};
const CM_CMT_TEXTS = ['완전 공감합니다 ㅋㅋ', '좋은 글 감사합니다. 추천 누르고 갑니다', '이거 진짜 유용하네요. 저장해둡니다', '저는 생각이 좀 다른데... 그래도 잘 봤습니다', '댓글 보고 바로 해봤는데 효과 있음', '이런 글 많이 올라오면 좋겠어요', '쪽지 보냈습니다!', '오 나도 어제 비슷한 경험 함', '정리 깔낁하네요 잘 읽었습니다', '이거 때문에 요즘 매치 분위기 좋아짐'];
const CM_CMT_NICKS = ['안개손절이', '플래그팔로우', '리스폰지옥', '점수지키미', '안전제일', '한발더', '에임수엽', '무릎샇소리'];
function cmDetail(p) {
const seed = (p.id * 37) % 9973;
const n = 2 + (seed % 5);
const comments = [];
for (let i = 0; i < n; i++) {
comments.push({
author: CM_CMT_NICKS[(seed + i * 3) % CM_CMT_NICKS.length],
text: CM_CMT_TEXTS[(seed + i * 5) % CM_CMT_TEXTS.length],
time: `${1 + ((seed + i * 7) % 20)}시간 전`,
up: (seed + i * 11) % 18,
replies: i === 0 ? [{ author: CM_CMT_NICKS[(seed + 5) % CM_CMT_NICKS.length], text: '맞아요, 저도 비슷하게 느꼈습니다.', time: `${1 + (seed % 12)}시간 전` }] : [],
});
}
return { body: p.body || CM_BODIES[p.board], comments };
}
// ── 목록 행 ──
function CmRow({ p, zebra, onOpen }) {
return (
<div onClick={() => onOpen && onOpen(p)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px', background: zebra ? '#fbfbfc' : '#fff', borderBottom: '1px solid #eef0f2', cursor: 'pointer' }}>
<span style={{ flexShrink: 0, fontSize: 10.5, fontWeight: 700, padding: '1px 7px', borderRadius: 4, background: '#f1f3f5', border: '1px solid var(--line)', color: '#5a5f66', minWidth: 44, textAlign: 'center' }}>{p.board}</span>
<span style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ minWidth: 0, flexShrink: 1, fontSize: 13.5, lineHeight: 1.3, color: '#33373d', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.title}</span>
{p.hasImage && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--t3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} title="사진 포함">
<rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="M21 15l-5-5L5 21" />
</svg>
)}
{p.cmt > 0 && <span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 11.5, lineHeight: 1, fontWeight: 700, color: CMPT }}>[{p.cmt}]</span>}
{p.hot && <span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 9.5, lineHeight: 1, fontWeight: 800, color: '#b23b3b' }}>HOT</span>}
{p.party && (
<span style={{ flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{cmPartyBadges(p.party).slice(0, 3).map((b) => (
<span key={b} style={{ display: 'inline-flex', alignItems: 'center', height: 18, fontSize: 9.5, fontWeight: 700, lineHeight: 1, color: 'var(--t2)', border: '1px solid var(--line2)', borderRadius: 3, padding: '0 5px', boxSizing: 'border-box' }}>{b}</span>
))}
</span>
)}
</span>
<span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 11, width: 52, textAlign: 'right', color: p.up >= CM_BEST_UP ? CMPT : 'var(--t3)', fontWeight: p.up >= CM_BEST_UP ? 700 : 400 }}>추천 {p.up}</span>
<span style={{ flexShrink: 0, width: 92, display: 'flex', justifyContent: 'flex-end' }}><CmAuthor name={p.author} style={{ fontSize: 12, color: 'var(--t2)' }} /></span>
<span style={{ flexShrink: 0, fontSize: 11, color: 'var(--t3)', width: 56, textAlign: 'right' }}>{p.time}</span>
<span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 11, color: 'var(--t3)', width: 52, textAlign: 'right' }}>{window.SA.fmt(p.views)}</span>
</div>
);
}
// ── 사이드 위젯 ──
function CmSidePanel({ title, en, posts, valueOf, onOpen }) {
return (
<Panel title={title} en={en} bodyPad="4px 0 6px">
{posts.map((p, i) => (
<div key={p.id} onClick={() => onOpen && onOpen(p)} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '9px 14px', borderBottom: i < posts.length - 1 ? '1px solid #f1f2f4' : 'none', cursor: 'pointer' }}>
<span style={{ width: 14, textAlign: 'center', fontFamily: CMCH, fontWeight: 700, fontSize: 12.5, lineHeight: 1, color: i < 3 ? CMPT : 'var(--t3)', flexShrink: 0 }}>{i + 1}</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, lineHeight: 1.3, color: '#33373d', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.title}</span>
<span style={{ flexShrink: 0, fontFamily: CMCH, fontSize: 10.5, lineHeight: 1, color: 'var(--t3)', whiteSpace: 'nowrap' }}>{valueOf(p)}</span>
</div>
))}
</Panel>
);
}
// ── 글 상세 ──
function CmPostDetail({ p, onBack }) {
const d = React.useMemo(() => cmDetail(p), [p.id]);
const [comments, setComments] = React.useState(d.comments);
const [draft, setDraft] = React.useState('');
const [up, setUp] = React.useState(p.up);
const [voted, setVoted] = React.useState(0); // 0 없음 / 1 추천 / -1 비추
React.useEffect(() => { setComments(d.comments); setDraft(''); setUp(p.up); setVoted(0); setCmtVoted({}); }, [p.id]);
const vote = (v) => {
if (voted === v) { setVoted(0); setUp(up - v); return; }
setUp(up - voted + v);
setVoted(v);
};
const delCmt = (i) => setComments(comments.map((c, idx) => idx === i ? { ...c, deleted: true } : c));
const post = () => {
const text = draft.trim();
if (!text) return;
setComments([...comments, { author: window.SA.me.name, text, time: '방금 전', up: 0, replies: [] }]);
setDraft('');
};
const [replyAt, setReplyAt] = React.useState(-1);
const [replyDraft, setReplyDraft] = React.useState('');
const [cmtVoted, setCmtVoted] = React.useState({});
const voteCmt = (i) => setCmtVoted((v) => ({ ...v, [i]: !v[i] }));
const postReply = (ci) => {
const text = replyDraft.trim();
if (!text) return;
setComments(comments.map((c, idx) => idx === ci ? { ...c, replies: [...(c.replies || []), { author: window.SA.me.name, text, time: '방금 전' }] } : c));
setReplyDraft('');
setReplyAt(-1);
};
return (
<div style={{ background: '#fff', border: '1px solid var(--line)', borderRadius: 8, overflow: 'hidden' }}>
{/* 상단 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: '1px solid var(--line)' }}>
<button onClick={onBack} style={{ display: 'flex', alignItems: 'center', gap: 4, border: '1px solid var(--line2)', background: '#fff', borderRadius: 6, padding: '6px 12px', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, color: 'var(--t2)' }}>
<Icon name="chevL" size={13} stroke="var(--t2)" /> 목록
</button>
<span style={{ fontSize: 10.5, fontWeight: 700, padding: '1px 7px', borderRadius: 4, background: '#f1f3f5', border: '1px solid var(--line)', color: '#5a5f66' }}>{p.board}</span>
</div>
{/* 제목 + 메타 */}
<div style={{ padding: '16px 18px 14px', borderBottom: '1px solid var(--line)' }}>
<h2 style={{ margin: 0, fontSize: 19, fontWeight: 800, color: 'var(--ink)', letterSpacing: '-.3px', lineHeight: 1.4 }}>{p.title}</h2>
<div style={{ marginTop: 9, display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--t3)', flexWrap: 'wrap' }}>
<CmAuthor name={p.author} style={{ fontWeight: 700, color: 'var(--t2)' }} />
<span>·</span><span>{p.time}</span>
<span>·</span><span>조회 <b style={{ fontFamily: CMCH, color: 'var(--t2)' }}>{window.SA.fmt(p.views)}</b></span>
<span>·</span><span>추천 <b style={{ fontFamily: CMCH, color: up >= CM_BEST_UP ? CMPT : 'var(--t2)' }}>{up}</b></span>
</div>
{p.party && (
<div style={{ marginTop: 11, display: 'flex', gap: 6 }}>
{cmPartyBadges(p.party).map((b) => (
<span key={b} style={{ display: 'inline-flex', alignItems: 'center', height: 22, fontSize: 11, fontWeight: 700, color: 'var(--t2)', border: '1px solid var(--line2)', borderRadius: 4, padding: '0 8px' }}>{b}</span>
))}
</div>
)}
</div>
{/* 본문 */}
<div style={{ padding: '18px 18px 8px', fontSize: 14, lineHeight: 1.8, color: '#33373d', whiteSpace: 'pre-line' }}>{d.body}</div>
{/* 추천/비추 */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, padding: '16px 0 20px' }}>
<button onClick={() => vote(1)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '9px 18px', borderRadius: 7, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 13,
border: `1px solid ${voted === 1 ? CMPT : 'var(--line2)'}`, background: voted === 1 ? CMPT : '#fff', color: voted === 1 ? '#fff' : 'var(--t2)' }}>
추천 <span style={{ fontFamily: CMCH }}>{up}</span>
</button>
<button onClick={() => vote(-1)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '9px 18px', borderRadius: 7, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 13,
border: `1px solid ${voted === -1 ? '#b23b3b' : 'var(--line2)'}`, background: voted === -1 ? '#b23b3b' : '#fff', color: voted === -1 ? '#fff' : 'var(--t2)' }}>
비추
</button>
</div>
{/* 댓글 */}
<div style={{ borderTop: '1px solid var(--line)', padding: '14px 18px 18px' }}>
<div style={{ fontSize: 13, fontWeight: 800, color: 'var(--ink)', marginBottom: 10 }}>댓글 <span style={{ fontFamily: CMCH, color: CMPT }}>{comments.length}</span></div>
{comments.map((c, i) => (
<div key={i} style={{ display: 'flex', gap: 9, padding: '10px 0', borderTop: i ? '1px solid #f1f2f4' : 'none' }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: '#eef0f2', color: '#7a8089', display: 'grid', placeItems: 'center', fontWeight: 700, fontSize: 12, flexShrink: 0, opacity: c.deleted ? .5 : 1 }}>{c.deleted ? '' : c.author.slice(0, 1)}</div>
<div style={{ flex: 1, minWidth: 0 }}>
{c.deleted ? (
<div style={{ fontSize: 13, color: 'var(--t3)', fontStyle: 'italic', padding: '5px 0' }}>삭제된 댓글입니다.</div>
) : (
<React.Fragment>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
<CmAuthor name={c.author} style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink)' }} />
<span style={{ fontSize: 11, color: 'var(--t3)' }}>{c.time}</span>
<button onClick={() => { setReplyAt(replyAt === i ? -1 : i); setReplyDraft(''); }} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11, color: CMPT, padding: 0 }}>
{replyAt === i ? '취소' : '답글'}
</button>
{c.author === window.SA.me.name && (
<button onClick={() => delCmt(i)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11, color: '#b23b3b', padding: 0 }}>삭제</button>
)}
<button onClick={() => voteCmt(i)} title="댓글 추천" style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 4, border: `1px solid ${cmtVoted[i] ? CMPT : 'var(--line2)'}`, background: cmtVoted[i] ? CMPT : '#fff', color: cmtVoted[i] ? '#fff' : 'var(--t3)', borderRadius: 5, padding: '3px 8px', cursor: 'pointer', fontFamily: CMCH, fontSize: 10.5, fontWeight: 700 }}>
{c.up + (cmtVoted[i] ? 1 : 0)}
</button>
</div>
<div style={{ fontSize: 13, color: '#3a4049', marginTop: 3, lineHeight: 1.55 }}>{c.text}</div>
</React.Fragment>
)}
{/* 답글 목록 */}
{(c.replies || []).map((rp, j) => (
<div key={j} style={{ display: 'flex', gap: 9, marginTop: 13, paddingLeft: 10, borderLeft: `2px solid ${CMPT}33` }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: `${CMPT}1c`, color: CMPT, display: 'grid', placeItems: 'center', fontWeight: 800, fontSize: 12, flexShrink: 0 }}>{rp.author.slice(0, 1)}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
<CmAuthor name={rp.author} style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink)' }} />
<span style={{ fontSize: 11, color: 'var(--t3)' }}>{rp.time}</span>
</div>
<div style={{ fontSize: 13, color: '#3a4049', marginTop: 3, lineHeight: 1.55 }}>{rp.text}</div>
</div>
</div>
))}
{/* 답글 입력 */}
{replyAt === i && (
<div style={{ display: 'flex', gap: 7, marginTop: 13, paddingLeft: 10, borderLeft: `2px solid ${CMPT}33` }}>
<input autoFocus value={replyDraft} onChange={(e) => setReplyDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') postReply(i); }} placeholder="답글을 남겨보세요"
style={{ flex: 1, minWidth: 0, border: '1px solid var(--line2)', borderRadius: 6, padding: '7px 10px', fontSize: 12.5, fontFamily: "'Pretendard',sans-serif", outline: 'none' }} />
<button onClick={() => postReply(i)} disabled={!replyDraft.trim()} style={{ padding: '0 13px', borderRadius: 6, border: 'none', background: replyDraft.trim() ? CMPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 11.5, cursor: replyDraft.trim() ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif", flexShrink: 0 }}>등록</button>
</div>
)}
</div>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') post(); }} placeholder="댓글을 남겨보세요"
style={{ flex: 1, minWidth: 0, border: '1px solid var(--line2)', borderRadius: 6, padding: '10px 12px', fontSize: 13, fontFamily: "'Pretendard',sans-serif", outline: 'none' }} />
<button onClick={post} disabled={!draft.trim()} style={{ padding: '0 18px', borderRadius: 6, border: 'none', background: draft.trim() ? CMPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 13, cursor: draft.trim() ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif", flexShrink: 0 }}>등록</button>
</div>
</div>
</div>
);
}
// ── 글쓰기 화면 (일반 / 파티 모집) ──
function CmWrite({ initialBoard, onCancel, onSubmit }) {
const [board, setBoard] = React.useState(initialBoard && initialBoard !== '전체' ? initialBoard : '자유');
const [title, setTitle] = React.useState('');
const [body, setBody] = React.useState('');
const [hasImage, setHasImage] = React.useState(false);
const fileRef = React.useRef(null);
// 파티 모집 전용
const [ptype, setPtype] = React.useState(CM_PARTY_TYPES[0]);
const [voice, setVoice] = React.useState(true);
const isParty = board === '파티 모집';
const valid = title.trim() && body.trim();
const label = { fontSize: 12, fontWeight: 700, color: 'var(--t2)', marginBottom: 7 };
const chip = (on) => ({ padding: '6px 12px', borderRadius: 5, border: `1px solid ${on ? 'var(--ink)' : 'var(--line2)'}`, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, background: on ? 'var(--ink)' : '#fff', color: on ? '#fff' : 'var(--t2)' });
const inputBase = { width: '100%', border: '1px solid var(--line2)', borderRadius: 7, padding: '10px 12px', fontSize: 13.5, fontFamily: "'Pretendard',sans-serif", color: 'var(--ink)', outline: 'none', boxSizing: 'border-box' };
const submit = () => {
if (!valid) return;
onSubmit({
board, title: title.trim(),
author: window.SA.me.name, time: '방금 전', hoursAgo: 0,
views: 1, cmt: 0, up: 0, hot: false, hasImage,
body: body.trim(),
party: isParty ? { ptype, voice } : null,
});
};
return (
<div style={{ background: '#fff', border: '1px solid var(--line)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '13px 16px', borderBottom: '1px solid var(--line)' }}>
<span style={{ fontSize: 15.5, fontWeight: 800, color: 'var(--ink)' }}>글쓰기</span>
<button onClick={onCancel} style={{ border: '1px solid var(--line2)', background: '#fff', borderRadius: 6, padding: '6px 12px', cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12, color: 'var(--t2)' }}>목록</button>
</div>
<div style={{ padding: '18px 18px 20px' }}>
{/* 게시판 */}
<div style={{ marginBottom: 16 }}>
<div style={label}>게시판</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{CM_BOARDS.map((b) => {
const on = board === b;
return <button key={b} onClick={() => setBoard(b)} style={{ padding: '7px 14px', borderRadius: 20, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12.5, border: `1px solid ${on ? CMPT : 'var(--line2)'}`, background: on ? CMPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{b}</button>;
})}
</div>
</div>
{/* 제목 */}
<div style={{ marginBottom: 16 }}>
<div style={label}>제목</div>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={isParty ? '예: 저녁 9시 파티 랭크전 같이 하실 분' : '제목을 입력하세요'} style={inputBase} />
</div>
{/* 모집 유형 */}
{isParty && (
<div style={{ marginBottom: 16 }}>
<div style={label}>모집 유형</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{CM_PARTY_TYPES.map((t) => (
<button key={t} onClick={() => setPtype(t)} style={chip(ptype === t)}>{t}</button>
))}
</div>
</div>
)}
{/* 참여 조건 */}
{isParty && (
<div style={{ marginBottom: 16 }}>
<div style={label}>참여 조건</div>
<button onClick={() => setVoice((v) => !v)} style={chip(voice)}>보이스 채팅 필수</button>
</div>
)}
{/* 내용 */}
<div style={{ marginBottom: 14 }}>
<div style={label}>내용</div>
<textarea value={body} onChange={(e) => setBody(e.target.value)} rows={10} placeholder={isParty ? '활동 시간대, 분위기, 디스코드/오픈카톡 연락 방법 등을 적어주세요.' : '내용을 입력하세요.'} style={{ ...inputBase, resize: 'vertical', lineHeight: 1.7 }} />
</div>
{/* 사진 첨부 */}
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => { if (e.target.files && e.target.files.length) setHasImage(true); e.target.value = ''; }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
<button onClick={() => fileRef.current && fileRef.current.click()} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 13px', borderRadius: 7, cursor: 'pointer', background: '#fff', border: '1px dashed var(--line2)', color: 'var(--t2)', fontWeight: 700, fontSize: 12, fontFamily: "'Pretendard',sans-serif" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="M21 15l-5-5L5 21" /></svg>
사진 첨부
</button>
{hasImage && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--t2)', fontWeight: 600 }}>
사진 1 첨부됨
<button onClick={() => setHasImage(false)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--t3)', fontSize: 14, padding: 0 }}>×</button>
</span>
)}
</div>
{/* 버튼 */}
<div style={{ display: 'flex', gap: 10, borderTop: '1px solid var(--line)', paddingTop: 16 }}>
<button onClick={onCancel} style={{ flex: 1, padding: '12px 0', borderRadius: 7, border: '1px solid var(--line2)', background: '#fff', color: '#3a4049', fontWeight: 700, fontSize: 14, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>취소</button>
<button onClick={submit} disabled={!valid} style={{ flex: 2, padding: '12px 0', borderRadius: 7, border: 'none', background: valid ? CMPT : '#c4c8ce', color: '#fff', fontWeight: 700, fontSize: 14, cursor: valid ? 'pointer' : 'not-allowed', fontFamily: "'Pretendard',sans-serif" }}>등록하기</button>
</div>
</div>
</div>
);
}
// ── 커뮤니티 화면 ──
function CommunityScreen({ onClose, onLogin, onClan, onCommunity }) {
const [per, setPer] = React.useState(15);
const [board, setBoard] = React.useState('전체');
const [ptypeF, setPtypeF] = React.useState('전체');
const [voiceF, setVoiceF] = React.useState('전체');
const [q, setQ] = React.useState('');
const [qScope, setQScope] = React.useState('제목');
const [page, setPage] = React.useState(1);
const [view, setView] = React.useState(null);
const [writing, setWriting] = React.useState(false);
const [extra, setExtra] = React.useState([]); // 새로 쓴 글
const allPosts = [...extra, ...CM_POSTS];
let list = board === '전체' ? allPosts : allPosts.filter((p) => p.board === board);
if (board === '파티 모집') {
if (ptypeF !== '전체') list = list.filter((p) => p.party && p.party.ptype === ptypeF);
if (voiceF !== '전체') list = list.filter((p) => p.party && (voiceF === '필수' ? p.party.voice : !p.party.voice));
}
if (q.trim()) {
const k = q.trim();
list = list.filter((p) =>
qScope === '제목' ? p.title.includes(k)
: qScope === '작성자' ? p.author.includes(k)
: (CM_BODIES[p.board] || '').includes(k)
);
}
const totalPages = Math.max(1, Math.ceil(list.length / per));
const cur = Math.min(page, totalPages);
const rows = list.slice((cur - 1) * per, cur * per);
const goPage = (n) => setPage(Math.max(1, Math.min(totalPages, n)));
const hot = [...CM_POSTS].sort((a, b) => b.views - a.views).slice(0, 5);
const best = [...CM_POSTS].sort((a, b) => b.up - a.up).slice(0, 5);
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'var(--bg2)', display: 'flex', flexDirection: 'column', animation: 'sgFade .22s ease' }}>
<window.Nav active="커뮤니티" onLogin={onLogin} onClan={onClan} />
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ maxWidth: 1180, margin: '0 auto', padding: '24px 28px 46px' }}>
{/* 타이틀 + 검색 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16, flexWrap: 'wrap' }}>
<div>
<div style={{ fontFamily: CMCH, fontSize: 10.5, fontWeight: 700, letterSpacing: '2.2px', color: 'var(--t3)' }}>COMMUNITY</div>
<h1 style={{ margin: '3px 0 0', fontSize: 24, fontWeight: 800, color: 'var(--ink)', letterSpacing: '-.4px' }}>커뮤니티</h1>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<select value={qScope} onChange={(e) => { setQScope(e.target.value); setPage(1); }} style={{ fontFamily: "'Pretendard',sans-serif", fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', border: '1px solid var(--line2)', borderRadius: 7, padding: '9px 10px', background: '#fff', cursor: 'pointer' }}>
{['제목', '작성자', '내용'].map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<div style={{ position: 'relative', width: 250 }}>
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', display: 'flex' }}><Icon name="search" size={15} stroke="var(--t3)" /></span>
<input value={q} onChange={(e) => { setQ(e.target.value); setPage(1); }} placeholder={`${qScope} 검색`}
style={{ width: '100%', height: 38, paddingLeft: 36, paddingRight: 12, borderRadius: 7, border: '1px solid var(--line2)', background: '#fff', fontSize: 13, outline: 'none', fontFamily: "'Pretendard',sans-serif", color: 'var(--ink)', boxSizing: 'border-box' }} />
</div>
</div>
<button onClick={() => { setWriting(true); setView(null); }} style={{ padding: '10px 18px', borderRadius: 7, border: 'none', background: CMPT, color: '#fff', fontWeight: 700, fontSize: 13, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif" }}>글쓰기</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 18, alignItems: 'start' }}>
{/* 본문: 글쓰기 / 상세 / 게시판 */}
{writing ? (
<CmWrite initialBoard={board} onCancel={() => setWriting(false)} onSubmit={(p) => {
const np = { ...p, id: 100000 + extra.length };
setExtra([np, ...extra]);
setWriting(false);
setBoard(p.board);
setPage(1);
setView(np);
}} />
) : view ? <CmPostDetail p={view} onBack={() => setView(null)} /> : (
<div style={{ background: '#fff', border: '1px solid var(--line)', borderRadius: 8, overflow: 'hidden' }}>
{/* 게시판 탭 */}
<div style={{ display: 'flex', gap: 6, padding: '12px 14px', borderBottom: '1px solid var(--line)', overflowX: 'auto', whiteSpace: 'nowrap' }}>
{['전체', ...CM_BOARDS].map((b) => {
const on = board === b;
return (
<button key={b} onClick={() => { setBoard(b); setPage(1); if (b !== '파티 모집') { setPtypeF('전체'); setVoiceF('전체'); } }} style={{ flexShrink: 0, padding: '6px 14px', borderRadius: 20, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 12.5,
border: `1px solid ${on ? CMPT : 'var(--line2)'}`, background: on ? CMPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{b}</button>
);
})}
<span style={{ marginLeft: 'auto', alignSelf: 'center', fontSize: 11.5, color: 'var(--t3)', fontWeight: 600, flexShrink: 0 }}>{list.length} </span>
<select value={per} onChange={(e) => { setPer(Number(e.target.value)); setPage(1); }} style={{ alignSelf: 'center', flexShrink: 0, fontFamily: "'Pretendard',sans-serif", fontSize: 11.5, fontWeight: 700, color: 'var(--ink)', border: '1px solid var(--line2)', borderRadius: 5, padding: '5px 7px', background: '#fff', cursor: 'pointer' }}>
{[15, 30, 50].map((n) => <option key={n} value={n}>{n}개씩</option>)}
</select>
</div>
{/* 파티 모집 조건 필터 */}
{board === '파티 모집' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderBottom: '1px solid var(--line)', background: '#fafbfc', flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--t3)', flexShrink: 0 }}>모집 유형</span>
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
{['전체', ...CM_PARTY_TYPES].map((t) => {
const on = ptypeF === t;
return <button key={t} onClick={() => { setPtypeF(t); setPage(1); }} style={{ padding: '5px 11px', borderRadius: 20, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11.5, border: `1px solid ${on ? CMPT : 'var(--line2)'}`, background: on ? CMPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{t}</button>;
})}
</div>
<span style={{ width: 1, height: 16, background: 'var(--line2)', flexShrink: 0 }} />
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--t3)', flexShrink: 0 }}>보이스</span>
<div style={{ display: 'flex', gap: 5 }}>
{['전체', '필수', '자율'].map((t) => {
const on = voiceF === t;
return <button key={t} onClick={() => { setVoiceF(t); setPage(1); }} style={{ padding: '5px 11px', borderRadius: 20, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", fontWeight: 700, fontSize: 11.5, border: `1px solid ${on ? CMPT : 'var(--line2)'}`, background: on ? CMPT : '#fff', color: on ? '#fff' : 'var(--t2)' }}>{t}</button>;
})}
</div>
{(ptypeF !== '전체' || voiceF !== '전체') && (
<button onClick={() => { setPtypeF('전체'); setVoiceF('전체'); setPage(1); }} style={{ marginLeft: 'auto', border: 'none', background: 'transparent', color: 'var(--t2)', fontWeight: 700, fontSize: 11.5, cursor: 'pointer', fontFamily: "'Pretendard',sans-serif", textDecoration: 'underline', flexShrink: 0 }}>초기화</button>
)}
</div>
)}
{CM_NOTICES.map((n, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px', background: '#f4f6f3', borderBottom: '1px solid #e6e9e4', cursor: 'pointer' }}>
<span style={{ flexShrink: 0, fontSize: 10.5, fontWeight: 800, padding: '1px 7px', borderRadius: 4, background: CMPT, color: '#fff', minWidth: 44, textAlign: 'center', boxSizing: 'border-box' }}>공지</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, color: 'var(--ink)', fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n.title}</span>
<span style={{ flexShrink: 0, fontSize: 11.5, color: 'var(--t3)', fontWeight: 600 }}>{n.time}</span>
</div>
))}
{/* 목록 */}
{rows.map((p, i) => <CmRow key={p.id} p={p} zebra={i % 2 === 1} onOpen={setView} />)}
{rows.length === 0 && (
<div style={{ padding: '36px 0', textAlign: 'center', fontSize: 13, color: 'var(--t3)' }}>검색 결과가 없어요.</div>
)}
{/* 페이지네이션 */}
{totalPages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '14px 0' }}>
<button onClick={() => goPage(cur - 1)} disabled={cur === 1} style={{ width: 30, height: 30, borderRadius: 6, border: '1px solid var(--line2)', background: '#fff', cursor: cur === 1 ? 'default' : 'pointer', opacity: cur === 1 ? .4 : 1, display: 'grid', placeItems: 'center' }}>
<Icon name="chevL" size={14} stroke="var(--t2)" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<button key={n} onClick={() => goPage(n)} style={{ minWidth: 30, height: 30, padding: '0 6px', borderRadius: 6, cursor: 'pointer', fontFamily: CMCH, fontWeight: 700, fontSize: 12.5,
border: n === cur ? 'none' : '1px solid var(--line2)', background: n === cur ? CMPT : '#fff', color: n === cur ? '#fff' : 'var(--t2)' }}>{n}</button>
))}
<button onClick={() => goPage(cur + 1)} disabled={cur === totalPages} style={{ width: 30, height: 30, borderRadius: 6, border: '1px solid var(--line2)', background: '#fff', cursor: cur === totalPages ? 'default' : 'pointer', opacity: cur === totalPages ? .4 : 1, display: 'grid', placeItems: 'center' }}>
<Icon name="chevR" size={14} stroke="var(--t2)" />
</button>
</div>
)}
</div>
)}
{/* 사이드 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<CmSidePanel title="인기글" en="HOT" posts={hot} valueOf={(p) => `조회 ${window.SA.fmt(p.views)}`} onOpen={setView} />
<CmSidePanel title="주간 베스트" en="WEEKLY BEST" posts={best} valueOf={(p) => `추천 ${p.up}`} onOpen={setView} />
</div>
</div>
</div>
</div>
</div>
);
}
Object.assign(window, { CommunityScreen });