Skip to content

Commit 3eab359

Browse files
authored
Help members find and audit community activity (#246)
Members needed upgrades beyond admin-only moderation, so the community list now searches body content, supports useful sort modes, and shows a clearer deleted-post timeline without changing backend contracts. Constraint: Keep the existing Spring API and manual Community page structure. Rejected: Add backend search endpoints | current post volume can use local filtering with lower deploy risk. Confidence: high Scope-risk: narrow Directive: Keep member-visible deletion evidence readable before adding more admin workflow. Tested: npm test; npm run lint; npm run build; npm run smoke
1 parent d1d69fa commit 3eab359

5 files changed

Lines changed: 354 additions & 18 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Member Experience Upgrades Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Make the COMS community easier for members to search, sort, and audit after a deletion.
6+
7+
**Architecture:** Keep backend contracts unchanged and move member-facing list logic into a small pure utility. The Community page consumes the utility for body-aware search, deterministic sorting, and deleted-post timelines.
8+
9+
**Tech Stack:** React, Vite, Node contract tests, Playwright smoke tests.
10+
11+
---
12+
13+
### Task 1: Community List Search And Sorting
14+
15+
**Files:**
16+
- Create: `src/utils/communityExperience.js`
17+
- Create: `tests/communityExperience.test.mjs`
18+
- Modify: `src/pages/Community.jsx`
19+
- Modify: `package.json`
20+
21+
- [x] **Step 1: Write the failing test**
22+
23+
```js
24+
assert.deepEqual(
25+
filterAndSortCommunityPosts(posts, {
26+
category: 'ALL',
27+
query: '투표 개선',
28+
sort: 'comments',
29+
canSeeAnonymous: true,
30+
}).map((post) => post.id),
31+
[2],
32+
)
33+
```
34+
35+
- [x] **Step 2: Run test to verify it fails**
36+
37+
Run: `node tests/communityExperience.test.mjs`
38+
Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/utils/communityExperience.js`.
39+
40+
- [x] **Step 3: Write minimal implementation**
41+
42+
Implement `filterAndSortCommunityPosts()` with anonymous filtering, category filtering, multi-term body-aware search, and `latest/comments/score/views` sorting.
43+
44+
- [x] **Step 4: Wire UI**
45+
46+
Add sort chips to `src/pages/Community.jsx` and route existing pagination through the utility.
47+
48+
- [x] **Step 5: Verify**
49+
50+
Run: `npm test`, `npm run lint`, `npm run build`, targeted Playwright smoke.
51+
52+
### Task 2: Deleted Post Timeline
53+
54+
**Files:**
55+
- Modify: `src/utils/communityExperience.js`
56+
- Modify: `src/pages/Community.jsx`
57+
- Modify: `tests/communityExperience.test.mjs`
58+
59+
- [x] **Step 1: Write the failing test**
60+
61+
```js
62+
const timeline = buildDeletedPostTimeline(record)
63+
assert.deepEqual(timeline.map((item) => item.label), ['작성됨', '삭제됨', '복원 요청', '검토 완료', '복원됨'])
64+
```
65+
66+
- [x] **Step 2: Run test to verify it fails**
67+
68+
Run: `node tests/communityExperience.test.mjs`
69+
Expected: FAIL before utility implementation.
70+
71+
- [x] **Step 3: Implement timeline builder**
72+
73+
Build timeline rows from `createdAt`, `deletedAt`, appeal fields, resolution fields, and `restoredAt`.
74+
75+
- [x] **Step 4: Render timeline**
76+
77+
Render a compact timeline in the member deleted-post card.
78+
79+
- [x] **Step 5: Verify**
80+
81+
Run: `npm test`.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"scripts": {
77
"dev": "vite",
88
"build": "vite build",
9+
"test": "node tests/communityExperience.test.mjs",
910
"lint": "eslint .",
1011
"preview": "vite preview",
1112
"e2e": "npm run build && playwright test",

src/pages/Community.jsx

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ import {
4747
} from '../services/communityApi.js'
4848
import { apiUrl } from '../services/apiClient.js'
4949
import { useAuth } from '../contexts/useAuth.js'
50+
import {
51+
buildDeletedPostTimeline,
52+
filterAndSortCommunityPosts,
53+
} from '../utils/communityExperience.js'
5054

5155
const PAGE_SIZE = 30
5256
const CONCEPT_POST_SCORE_THRESHOLD = 5
@@ -366,6 +370,13 @@ const CATEGORY_OPTIONS = [
366370
{ value: 'ANONYMOUS', label: '익명' },
367371
]
368372

373+
const SORT_OPTIONS = [
374+
{ value: 'latest', label: '최신순' },
375+
{ value: 'comments', label: '댓글 많은 순' },
376+
{ value: 'score', label: '추천순' },
377+
{ value: 'views', label: '조회순' },
378+
]
379+
369380
function categoryLabel(value) {
370381
return CATEGORY_OPTIONS.find((item) => item.value === value)?.label || '일반'
371382
}
@@ -1849,6 +1860,7 @@ export default function Community({ onBack }) {
18491860
const [deletedError, setDeletedError] = useState('')
18501861
const [page, setPage] = useState(1)
18511862
const [activeCategory, setActiveCategory] = useState('ALL')
1863+
const [sortMode, setSortMode] = useState('latest')
18521864
const [searchQuery, setSearchQuery] = useState('')
18531865
const [comments, setComments] = useState([])
18541866
const [commentInput, setCommentInput] = useState('')
@@ -1880,24 +1892,15 @@ export default function Community({ onBack }) {
18801892
return () => { mounted = false }
18811893
}, [])
18821894

1883-
const indexedPosts = useMemo(
1884-
() => posts.map((post) => ({
1885-
...post,
1886-
_searchKey: `${post.title} ${post.authorDisplayName || post.authorName || ''}`.toLowerCase(),
1887-
})).filter((post) => canSeeAnonymous || post.category !== 'ANONYMOUS'),
1888-
[posts, canSeeAnonymous]
1895+
const filteredPosts = useMemo(
1896+
() => filterAndSortCommunityPosts(posts, {
1897+
category: effectiveActiveCategory,
1898+
query: searchQuery,
1899+
sort: sortMode,
1900+
canSeeAnonymous,
1901+
}),
1902+
[canSeeAnonymous, effectiveActiveCategory, posts, searchQuery, sortMode],
18891903
)
1890-
1891-
const filteredPosts = useMemo(() => {
1892-
const byCategory = effectiveActiveCategory === 'ALL'
1893-
? indexedPosts
1894-
: effectiveActiveCategory === 'CONCEPT'
1895-
? indexedPosts.filter(isConceptPost)
1896-
: indexedPosts.filter((post) => (post.category || 'GENERAL') === effectiveActiveCategory)
1897-
if (!searchQuery.trim()) return byCategory
1898-
const q = searchQuery.toLowerCase()
1899-
return byCategory.filter((post) => post._searchKey.includes(q))
1900-
}, [effectiveActiveCategory, indexedPosts, searchQuery])
19011904
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / PAGE_SIZE))
19021905
const pageStartIndex = (page - 1) * PAGE_SIZE
19031906
const visiblePosts = useMemo(
@@ -2609,7 +2612,7 @@ export default function Community({ onBack }) {
26092612
type="text"
26102613
value={searchQuery}
26112614
onChange={(e) => { setSearchQuery(e.target.value); setPage(1) }}
2612-
placeholder="제목, 작성자 검색"
2615+
placeholder="제목, 본문, 작성자 검색"
26132616
className="h-11 w-full rounded-full border border-black/10 bg-white py-2 pl-9 pr-3 text-base text-[#1d1d1f] placeholder:text-[#86868b] outline-none transition focus:ring-2 focus:ring-[#0071e3]/24 sm:h-10 sm:w-64 sm:text-sm"
26142617
/>
26152618
</div>
@@ -2618,6 +2621,29 @@ export default function Community({ onBack }) {
26182621
</span>
26192622
</div>
26202623
</div>
2624+
<div className="-mx-1 overflow-x-auto pb-1">
2625+
<div className="flex min-w-max items-center gap-2 px-1 text-xs font-black text-[#6e6e73]">
2626+
<span className="shrink-0">정렬</span>
2627+
{SORT_OPTIONS.map((item) => (
2628+
<button
2629+
key={item.value}
2630+
type="button"
2631+
onClick={() => {
2632+
setSortMode(item.value)
2633+
setPage(1)
2634+
}}
2635+
className={`apple-chip min-h-9 px-3 py-1.5 ${sortMode === item.value ? 'apple-chip-active' : ''}`}
2636+
>
2637+
{item.label}
2638+
</button>
2639+
))}
2640+
{searchQuery.trim() && (
2641+
<span className="rounded-full border border-[#3b4890]/15 bg-[#f7f9ff] px-3 py-2 text-[#3b4890]">
2642+
본문까지 검색 중
2643+
</span>
2644+
)}
2645+
</div>
2646+
</div>
26212647
<div className="border-t border-black/10 pt-4">
26222648
{renderPagination('top')}
26232649
</div>
@@ -2724,6 +2750,7 @@ export default function Community({ onBack }) {
27242750
{!deletedLoading && deletedPosts.map((record) => {
27252751
const restored = Boolean(record.restoredPostId)
27262752
const appealed = Boolean(record.latestAppealStatus)
2753+
const timeline = buildDeletedPostTimeline(record)
27272754
return (
27282755
<article key={record.id} className="overflow-hidden rounded-lg border border-black/10 bg-white">
27292756
<div className="border-b border-black/10 px-4 py-4">
@@ -2758,6 +2785,27 @@ export default function Community({ onBack }) {
27582785
</p>
27592786
</div>
27602787
)}
2788+
{timeline.length > 0 && (
2789+
<div>
2790+
<strong className="mb-2 block text-xs text-[#3b4890]">처리 타임라인</strong>
2791+
<ol className="space-y-2">
2792+
{timeline.map((item, index) => (
2793+
<li key={`${record.id}-${item.label}-${index}`} className="grid grid-cols-[auto_1fr] gap-2 text-xs">
2794+
<span className="mt-1 size-2 rounded-full bg-[#3b4890]" aria-hidden="true" />
2795+
<span className="min-w-0">
2796+
<span className="font-black text-[#1d1d1f]">{item.label}</span>
2797+
{item.time && <span className="ml-2 text-[var(--theme-body-muted)]">{item.time}</span>}
2798+
{item.detail && (
2799+
<span className="block break-words text-[var(--theme-body-muted)]">
2800+
{item.label === '삭제됨' ? '처리자는 상단 기록에 표시됩니다.' : item.detail}
2801+
</span>
2802+
)}
2803+
</span>
2804+
</li>
2805+
))}
2806+
</ol>
2807+
</div>
2808+
)}
27612809
{restored ? (
27622810
<a href={`/community/${record.restoredPostId}`} className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-xs font-black text-emerald-700">
27632811
<RotateCcw size={14} />

src/utils/communityExperience.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
function plainText(value) {
2+
return String(value || '')
3+
.replace(/<[^>]+>/g, ' ')
4+
.replace(/\s+/g, ' ')
5+
.trim()
6+
}
7+
8+
function contentText(value, depth = 0) {
9+
if (value == null || depth > 3) return ''
10+
if (Array.isArray(value)) return value.map((item) => contentText(item, depth + 1)).filter(Boolean).join(' ')
11+
if (typeof value === 'object') {
12+
if (value.type === 'poll') return value.question || value.title || value.prompt || ''
13+
if (value.type === 'externalEmbed') return value.title || value.url || ''
14+
if (value.type === 'file' || value.type === 'image' || value.type === 'video') return value.name || ''
15+
return contentText(value.content ?? value.text ?? '', depth + 1)
16+
}
17+
const text = String(value || '').trim()
18+
if (!text) return ''
19+
if ((text.startsWith('[') || text.startsWith('{') || text.startsWith('"')) && /"type"\s*:/.test(text)) {
20+
try {
21+
return contentText(JSON.parse(text), depth + 1)
22+
} catch {
23+
return plainText(text)
24+
}
25+
}
26+
return plainText(text)
27+
}
28+
29+
function score(post) {
30+
return Number(post?.upvotes || 0) - Number(post?.downvotes || 0)
31+
}
32+
33+
function timestamp(value) {
34+
const time = new Date(value || 0).getTime()
35+
return Number.isFinite(time) ? time : 0
36+
}
37+
38+
function matchesCategory(post, category) {
39+
if (category === 'ALL') return true
40+
if (category === 'CONCEPT') return Boolean(post?.conceptPost) || score(post) >= 5
41+
return (post?.category || 'GENERAL') === category
42+
}
43+
44+
function searchKey(post) {
45+
return [
46+
post?.title,
47+
post?.authorDisplayName,
48+
post?.authorName,
49+
contentText(post?.content),
50+
].filter(Boolean).join(' ').toLowerCase()
51+
}
52+
53+
export function filterAndSortCommunityPosts(posts, {
54+
category = 'ALL',
55+
query = '',
56+
sort = 'latest',
57+
canSeeAnonymous = true,
58+
} = {}) {
59+
const q = String(query || '').trim().toLowerCase()
60+
const terms = q ? q.split(/\s+/).filter(Boolean) : []
61+
const filtered = (Array.isArray(posts) ? posts : [])
62+
.filter((post) => canSeeAnonymous || post?.category !== 'ANONYMOUS')
63+
.filter((post) => matchesCategory(post, category))
64+
.filter((post) => {
65+
if (terms.length === 0) return true
66+
const key = searchKey(post)
67+
return terms.every((term) => key.includes(term))
68+
})
69+
70+
return [...filtered].sort((a, b) => {
71+
if (sort === 'comments') return Number(b.commentCount || 0) - Number(a.commentCount || 0) || timestamp(b.createdAt) - timestamp(a.createdAt)
72+
if (sort === 'score') return score(b) - score(a) || timestamp(b.createdAt) - timestamp(a.createdAt)
73+
if (sort === 'views') return Number(b.viewCount || 0) - Number(a.viewCount || 0) || timestamp(b.createdAt) - timestamp(a.createdAt)
74+
return timestamp(b.createdAt) - timestamp(a.createdAt)
75+
})
76+
}
77+
78+
function formatTime(value) {
79+
if (!value) return ''
80+
return new Date(value).toLocaleString('ko-KR')
81+
}
82+
83+
function identity(name, studentId) {
84+
const safeName = name || '알 수 없음'
85+
return studentId ? `${safeName}(${studentId})` : safeName
86+
}
87+
88+
function appealStatusLabel(status) {
89+
if (status === 'APPROVED') return '검토 완료'
90+
if (status === 'REJECTED') return '검토 완료'
91+
return '복원 요청'
92+
}
93+
94+
export function buildDeletedPostTimeline(record) {
95+
const events = []
96+
if (record?.createdAt) {
97+
events.push({ label: '작성됨', time: formatTime(record.createdAt), detail: '커뮤니티에 게시' })
98+
}
99+
if (record?.deletedAt) {
100+
events.push({
101+
label: '삭제됨',
102+
time: formatTime(record.deletedAt),
103+
detail: identity(record.deletedByName, record.deletedByStudentId),
104+
})
105+
}
106+
if (record?.latestAppealCreatedAt) {
107+
events.push({
108+
label: '복원 요청',
109+
time: formatTime(record.latestAppealCreatedAt),
110+
detail: record.latestAppealMessage || '검토 대기 중',
111+
})
112+
}
113+
if (record?.latestAppealResolvedAt) {
114+
events.push({
115+
label: appealStatusLabel(record.latestAppealStatus),
116+
time: formatTime(record.latestAppealResolvedAt),
117+
detail: record.latestAppealResolutionNote || (record.latestAppealStatus === 'REJECTED' ? '요청 반려' : '요청 승인'),
118+
})
119+
}
120+
if (record?.restoredAt) {
121+
events.push({ label: '복원됨', time: formatTime(record.restoredAt), detail: '커뮤니티에 다시 표시됨' })
122+
}
123+
return events
124+
}

0 commit comments

Comments
 (0)