Skip to content

Commit ba07e83

Browse files
committed
feat: add Useful Links feature with category filter and improved list UI
1 parent 43889e5 commit ba07e83

File tree

3 files changed

+189
-0
lines changed

3 files changed

+189
-0
lines changed

components/UsefulLinks.tsx

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { useMemo, useState, useEffect } from "react";
2+
3+
export type Article = {
4+
title: string;
5+
url: string;
6+
category?: string;
7+
note?: string;
8+
added?: string; // YYYY-MM-DD
9+
};
10+
11+
function byAddedDesc(a?: string, b?: string) {
12+
const av = (a ?? "").trim();
13+
const bv = (b ?? "").trim();
14+
if (!av && !bv) { return 0; }
15+
if (!av) { return 1; }
16+
if (!bv) { return -1; }
17+
const ad = new Date(av).getTime() || 0;
18+
const bd = new Date(bv).getTime() || 0;
19+
return bd - ad;
20+
}
21+
22+
export default function UsefulLinks({ articles }: { articles?: Article[] }) {
23+
const [selected, setSelected] = useState<string>("All");
24+
25+
useEffect(() => {
26+
if (typeof window === "undefined") { return; } else {
27+
const p = new URLSearchParams(window.location.search);
28+
const init = p.get("category") || "All";
29+
setSelected(init);
30+
}
31+
}, []);
32+
33+
const safeArticles = useMemo(() => {
34+
const arr = Array.isArray(articles) ? articles : [];
35+
return arr
36+
.filter((a) => {
37+
if (!a) { return false; }
38+
if (typeof a.title !== "string") { return false; }
39+
if (typeof a.url !== "string") { return false; }
40+
return true;
41+
})
42+
.map((a) => {
43+
const category = a.category?.trim() || "Uncategorized";
44+
return { ...a, category };
45+
});
46+
}, [articles]);
47+
48+
useEffect(() => {
49+
if (typeof window === "undefined") { return; } else {
50+
const url = new URL(window.location.href);
51+
if (selected === "All") {
52+
url.searchParams.delete("category");
53+
} else {
54+
url.searchParams.set("category", selected);
55+
}
56+
window.history.replaceState({}, "", url.toString());
57+
}
58+
}, [selected]);
59+
60+
const chips = useMemo(() => {
61+
const counts = new Map<string, number>();
62+
for (const a of safeArticles) {
63+
const k = a.category!;
64+
counts.set(k, (counts.get(k) ?? 0) + 1);
65+
}
66+
const cats = Array.from(counts.entries())
67+
.map(([name, count]) => ({ name, count }))
68+
.sort((a, b) => a.name.localeCompare(b.name));
69+
return [{ name: "All", count: safeArticles.length }, ...cats];
70+
}, [safeArticles]);
71+
72+
const filtered = useMemo(() => {
73+
const base = selected === "All"
74+
? [...safeArticles]
75+
: safeArticles.filter((a) => a.category === selected);
76+
base.sort((a, b) => byAddedDesc(a.added, b.added));
77+
return base;
78+
}, [safeArticles, selected]);
79+
80+
if (safeArticles.length === 0) {
81+
return <div className="text-center py-12 text-zinc-500">등록된 아티클이 없습니다.</div>;
82+
} else {
83+
return (
84+
<div className="space-y-6">
85+
<div className="py-2">
86+
<nav className="flex flex-wrap justify-start gap-2 sm:gap-3">
87+
{chips.map(({ name, count }) => {
88+
const isActive = selected === name;
89+
return (
90+
<button
91+
key={name}
92+
onClick={() => { setSelected(name); }}
93+
className={`capitalize px-4 py-1.5 rounded-full text-sm font-medium transition-colors duration-150
94+
${isActive ? "bg-gray-800 text-white shadow-sm" : "bg-gray-100 text-gray-700 border border-gray-200 hover:bg-gray-200"}
95+
`}
96+
aria-pressed={isActive}
97+
>
98+
{name} ({count})
99+
</button>
100+
);
101+
})}
102+
</nav>
103+
</div>
104+
105+
<ol className="rounded-xl border border-zinc-200 dark:border-zinc-800 divide-y divide-zinc-200 dark:divide-zinc-800 overflow-hidden bg-white dark:bg-zinc-950">
106+
{filtered.map((a, idx) => (
107+
<li key={`${a.url}-${idx}`}>
108+
<a
109+
href={a.url}
110+
target="_blank"
111+
rel="noreferrer"
112+
className="block px-4 py-3 sm:px-5 sm:py-4 hover:bg-zinc-50 dark:hover:bg-zinc-900 transition"
113+
title={a.url}
114+
>
115+
<div className="flex items-start gap-3">
116+
<span className="mt-2 h-2.5 w-2.5 shrink-0 rounded-full bg-emerald-500/90" />
117+
<div className="min-w-0 flex-1">
118+
<p className="truncate font-semibold text-zinc-900 dark:text-zinc-50 hover:text-zinc-700 dark:hover:text-zinc-200">
119+
{a.title}
120+
</p>
121+
{a.note ? (
122+
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-300 line-clamp-2">
123+
{a.note}
124+
</p>
125+
) : null}
126+
</div>
127+
128+
<div className="flex flex-col items-end gap-1.5 shrink-0 pl-2">
129+
<span className="rounded-full border px-2 py-0.5 text-xs text-zinc-600 dark:text-zinc-300">
130+
{a.category}
131+
</span>
132+
{a.added ? (
133+
<span className="text-[11px] text-zinc-500">
134+
added : {a.added}
135+
</span>
136+
) : null}
137+
</div>
138+
</div>
139+
</a>
140+
</li>
141+
))}
142+
</ol>
143+
144+
{filtered.length === 0 ? (
145+
<p className="text-center text-zinc-500 py-10">선택한 카테고리에 해당하는 아티클이 없습니다.</p>
146+
) : null}
147+
</div>
148+
);
149+
}
150+
}

contents/link/data.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[
2+
{
3+
"title": "나는 왜 계속 불안할까? 개발자를 위한 불안 관리법",
4+
"url": "https://yozm.wishket.com/magazine/detail/3406/",
5+
"category": "Career",
6+
"note": "",
7+
"added": "2025-10-24"
8+
}
9+
]

pages/link.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import Header from "../components/Header";
2+
import UsefulLinks from "../components/UsefulLinks";
3+
import type { Article } from "../components/UsefulLinks";
4+
import data from "../contents/link/data.json";
5+
import {Home} from "lucide-react";
6+
import Link from "next/link";
7+
8+
export default function LinkPage() {
9+
const articles = (data as Article[]) ?? [];
10+
return (
11+
<>
12+
<div className="bg-white text-black dark:bg-black dark:text-white">
13+
<Header isDark={false} />
14+
<div className="w-full mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
15+
<h1 className="text-5xl font-extrabold tracking-tight mb-4">Useful Links</h1>
16+
</div>
17+
</div>
18+
<main className="mx-auto px-6 pb-20 max-w-5xl xl:max-w-6xl">
19+
<UsefulLinks articles={articles} />
20+
</main>
21+
<Link
22+
href="/"
23+
className="fixed bottom-6 right-6 z-50 bg-white border border-gray-300 rounded-lg shadow-md p-3 hover:shadow-lg transition"
24+
aria-label="홈으로 가기"
25+
>
26+
<Home className="w-6 h-6 text-gray-800"/>
27+
</Link>
28+
</>
29+
);
30+
}

0 commit comments

Comments
 (0)