Skip to content

Commit 883fe15

Browse files
committed
PostMeta 태그 필드 추가
1 parent ecd31b7 commit 883fe15

File tree

3 files changed

+180
-74
lines changed

3 files changed

+180
-74
lines changed

lib/model/post_meta.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@ import 'package:flutter/widgets.dart';
66
class PostMeta {
77
final String title;
88
final String category;
9+
final String tag;
910
final DateTime? date;
1011
final String slug;
1112

1213
PostMeta(
1314
{required this.title,
1415
required this.category,
16+
required this.tag,
1517
this.date,
1618
required this.slug});
1719

1820
factory PostMeta.fromJson(Map<String, dynamic> json) {
1921
final rawTitle = (json['title'] ?? '').toString().trim();
2022
final rawCategory = (json['category'] ?? 'Uncategorized').toString().trim();
23+
final rawTag = (json['tag'] ?? '').toString().trim();
2124
final rawDate = (json['date'] ?? '').toString().trim();
2225
DateTime? dt;
2326
if (rawDate.isNotEmpty) {
@@ -33,14 +36,19 @@ class PostMeta {
3336
.replaceAll(RegExp(r'^-+|-+$'), ''));
3437

3538
return PostMeta(
36-
title: rawTitle, category: rawCategory, date: dt, slug: rawSlug);
39+
title: rawTitle,
40+
category: rawCategory,
41+
tag: rawTag,
42+
date: dt,
43+
slug: rawSlug);
3744
}
3845

3946
PostMeta withFallbacks({required String fallbackTitle}) => PostMeta(
4047
title: title.isNotEmpty ? title : fallbackTitle,
4148
category: category.isNotEmpty ? category : 'Uncategorized',
4249
date: date,
4350
slug: slug,
51+
tag: tag.isNotEmpty ? tag : '',
4452
);
4553
}
4654

lib/pages/post_list_page.dart

Lines changed: 170 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ List<Item> generateItems(List<String> categories) {
2727
Item(
2828
headerValue: 'Post Categories',
2929
expandedValues: categories,
30-
isExpanded: false,
30+
isExpanded: true,
3131
)
3232
];
3333
}
@@ -43,9 +43,11 @@ class _PostListPageState extends State<PostListPage> {
4343
late DateFormat df;
4444
bool isShowPostDetail = false;
4545
late MdDoc currentPost;
46-
4746
List<Item> _data = [];
4847
List<String> categories = [];
48+
List<String> tags = [];
49+
String? _selectedCategory = 'All';
50+
String? _selectedTag;
4951

5052
@override
5153
void initState() {
@@ -54,11 +56,23 @@ class _PostListPageState extends State<PostListPage> {
5456
df = DateFormat('yyyy-MM-dd'); // 한국어 로케일이면 intl 초기화 추가 가능
5557

5658
future.then((docs) {
57-
final cats = docs.map((d) => d.meta.category).toSet().toList();
59+
final cats = docs
60+
.map((d) =>
61+
(d.meta.category).isNotEmpty ? d.meta.category : 'Uncategorized')
62+
.toSet()
63+
.toList();
64+
final tagList = docs
65+
.map((d) => (d.meta.tag).isNotEmpty ? d.meta.tag : '')
66+
.where((t) => t.isNotEmpty)
67+
.toSet()
68+
.toList();
5869
setState(() {
5970
categories = cats;
71+
tags = tagList;
6072
_data = generateItems(categories);
6173
});
74+
}).catchError((e) {
75+
debugPrint('load docs error: $e');
6276
});
6377
}
6478

@@ -67,14 +81,22 @@ class _PostListPageState extends State<PostListPage> {
6781
}
6882

6983
void _copyCurrentPostUrlToClipboard() {
70-
final postUrl =
71-
Uri.base.resolve('/posts/${currentPost.meta.slug}').toString();
72-
print('Copy URL: $postUrl');
73-
Clipboard.setData(ClipboardData(text: postUrl));
84+
try {
85+
final postPath = '/posts/${currentPost.meta.slug}';
86+
web.window.history.pushState(null, '', postPath);
7487

75-
ScaffoldMessenger.of(context).showSnackBar(
76-
const SnackBar(content: Text('URL copied to clipboard!')),
77-
);
88+
final postUrl = web.window.location.href;
89+
Clipboard.setData(ClipboardData(text: postUrl));
90+
91+
ScaffoldMessenger.of(context).showSnackBar(
92+
const SnackBar(content: Text('URL copied to clipboard!')),
93+
);
94+
} catch (e, st) {
95+
debugPrint('URL 복사 실패: $e\n$st');
96+
ScaffoldMessenger.of(context).showSnackBar(
97+
const SnackBar(content: Text('URL 복사에 실패했습니다.')),
98+
);
99+
}
78100
}
79101

80102
Widget _postDetail(MdDoc currentDoc) {
@@ -109,7 +131,8 @@ class _PostListPageState extends State<PostListPage> {
109131

110132
@override
111133
Widget build(BuildContext context) {
112-
Widget _buildPanel() {
134+
// 카테고리 패널
135+
Widget buildPanel() {
113136
return ExpansionPanelList(
114137
expansionCallback: (int index, bool isExpanded) {
115138
setState(() {
@@ -125,13 +148,28 @@ class _PostListPageState extends State<PostListPage> {
125148
body: item.hasExpandedValues
126149
? Column(
127150
children: item.expandedValues.map((value) {
151+
final selected = value == _selectedCategory;
128152
return ListTile(
129153
title: Text(value),
154+
selected: selected,
130155
onTap: () {
131-
// TODO: Implement category filtering
132-
print('Tapped on $value');
156+
setState(() {
157+
isShowPostDetail = false;
158+
_selectedCategory = value;
159+
_selectedTag = null;
160+
});
161+
final path = value == 'All'
162+
? '/'
163+
: '/category/${Uri.encodeComponent(value)}';
164+
try {
165+
web.window.history.pushState(null, value, path);
166+
} catch (e) {
167+
debugPrint('history pushState 실패: $e');
168+
}
133169
},
134-
trailing: const Icon(Icons.chevron_right),
170+
trailing: selected
171+
? const Icon(Icons.check)
172+
: const Icon(Icons.chevron_right),
135173
);
136174
}).toList(),
137175
)
@@ -142,62 +180,126 @@ class _PostListPageState extends State<PostListPage> {
142180
);
143181
}
144182

145-
Widget _buildPostList(String? category) {
146-
// all 카테고리면 전체, 아니면 해당 카테고리만 필터링
147-
// til 카테고리는 따로 구현?
183+
// 태그 칩
184+
Widget buildTagChips() {
185+
if (tags.isEmpty) return const SizedBox.shrink();
186+
return Padding(
187+
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
188+
child: Wrap(
189+
spacing: 8,
190+
children: tags.map((t) {
191+
final sel = t == _selectedTag;
192+
return ChoiceChip(
193+
label: Text(t),
194+
selected: sel,
195+
onSelected: (v) {
196+
setState(() {
197+
isShowPostDetail = false;
198+
_selectedTag = v ? t : null;
199+
if (v) _selectedCategory = null;
200+
});
148201

149-
return !isShowPostDetail
150-
? FutureBuilder<List<MdDoc>>(
151-
future: future,
152-
builder: (context, snap) {
153-
if (snap.connectionState != ConnectionState.done) {
154-
return const Center(child: CircularProgressIndicator());
202+
final path = v
203+
? '/tag/${Uri.encodeComponent(t)}'
204+
: (_selectedCategory != null && _selectedCategory != 'All'
205+
? '/category/${Uri.encodeComponent(_selectedCategory!)}'
206+
: '/');
207+
try {
208+
web.window.history.pushState(null, _selectedTag!, path);
209+
} catch (e) {
210+
debugPrint('history pushState 실패: $e');
155211
}
156-
if (snap.hasError) {
157-
return Center(child: Text('로드 실패: ${snap.error}'));
158-
}
159-
final docs = snap.data ?? [];
160-
if (docs.isEmpty) {
161-
return const Center(child: Text('문서가 없습니다.'));
162-
}
163-
return ListView.separated(
164-
itemCount: docs.length,
165-
separatorBuilder: (_, __) => const Divider(height: 1),
166-
itemBuilder: (context, i) {
167-
final d = docs[i];
168-
final dateStr = d.meta.date != null
169-
? df.format(d.meta.date!)
170-
: '날짜 정보 없음';
171-
return ListTile(
172-
title: Text(d.meta.title,
173-
maxLines: 1, overflow: TextOverflow.ellipsis),
174-
subtitle: Text('${d.meta.category} · $dateStr'),
175-
trailing: const Icon(Icons.chevron_right),
176-
onTap: () {
177-
setState(() {
178-
isShowPostDetail = true;
179-
currentPost = d;
180-
});
181-
},
182-
);
183-
},
184-
);
185212
},
186-
)
187-
: _postDetail(currentPost);
213+
);
214+
}).toList(),
215+
),
216+
);
217+
}
218+
219+
// 포스트 리스트
220+
Widget buildPostList(String? category) {
221+
// category: All, Language, Platform, Dev, Life, Book_Review
222+
// tag: TIL, 등등...
223+
if (category != null && category == 'All') {
224+
return !isShowPostDetail
225+
? FutureBuilder<List<MdDoc>>(
226+
future: future,
227+
builder: (context, snap) {
228+
if (snap.connectionState != ConnectionState.done) {
229+
return const Center(child: CircularProgressIndicator());
230+
}
231+
if (snap.hasError) {
232+
return Center(child: Text('로드 실패: ${snap.error}'));
233+
}
234+
var docs = snap.data ?? [];
235+
236+
if (_selectedTag != null && _selectedTag!.isNotEmpty) {
237+
docs =
238+
docs.where((d) => d.meta.tag == _selectedTag).toList();
239+
} else if (_selectedCategory != null &&
240+
_selectedCategory != 'All') {
241+
docs = docs
242+
.where((d) => d.meta.category == _selectedCategory)
243+
.toList();
244+
}
245+
if (docs.isEmpty) {
246+
return const Center(child: Text('문서가 없습니다.'));
247+
}
248+
return ListView.separated(
249+
itemCount: docs.length,
250+
separatorBuilder: (_, __) => const Divider(height: 1),
251+
itemBuilder: (context, i) {
252+
final d = docs[i];
253+
final dateStr = d.meta.date != null
254+
? df.format(d.meta.date!)
255+
: '날짜 정보 없음';
256+
return ListTile(
257+
title: Text(d.meta.title,
258+
maxLines: 1, overflow: TextOverflow.ellipsis),
259+
subtitle: Text('${d.meta.category} · $dateStr'),
260+
trailing: const Icon(Icons.chevron_right),
261+
onTap: () {
262+
final postPath =
263+
'/posts/${Uri.encodeComponent(d.meta.slug)}';
264+
// 주소 표시줄을 포스트 경로로 즉시 갱신
265+
web.window.history
266+
.pushState(null, d.meta.title, postPath);
267+
setState(() {
268+
isShowPostDetail = true;
269+
currentPost = d;
270+
});
271+
},
272+
);
273+
},
274+
);
275+
},
276+
)
277+
: _postDetail(currentPost);
278+
} else if (category != null && category == 'TIL') {
279+
return const Center(child: Text('TIL 카테고리 페이지는 미구현 상태입니다.'));
280+
} else {
281+
return const Center(child: Text('카테고리를 선택해주세요.'));
282+
}
188283
}
189284

190285
return Scaffold(
191286
appBar: !isShowPostDetail
192287
? AppBar(
288+
elevation: 0,
193289
title: const Text('Blog Posts'),
290+
shape: const Border(
291+
bottom: BorderSide(color: Colors.grey, width: 0.5),
292+
),
194293
)
195294
: AppBar(
196295
elevation: 0,
197296
scrolledUnderElevation: 0,
198297
shadowColor: Colors.transparent,
199298
surfaceTintColor: Colors.transparent,
200299
forceMaterialTransparency: true,
300+
shape: const Border(
301+
bottom: BorderSide(color: Colors.grey, width: 0.5),
302+
),
201303
title: Text(currentPost.meta.title),
202304
leading: BackButton(
203305
onPressed: () => setState(() {
@@ -223,18 +325,18 @@ class _PostListPageState extends State<PostListPage> {
223325
width: width * 0.3,
224326
child: Column(
225327
children: [
226-
_buildPanel(),
227-
ListTile(
228-
title: const Text('TIL (Today I Learned)'),
229-
onTap: () {
230-
// TODO: Implement TIL navigation
231-
},
328+
buildPanel(),
329+
const ListTile(
330+
title: Center(child: Text('Tags')),
232331
),
332+
Padding(
333+
padding: const EdgeInsets.all(8.0),
334+
child: buildTagChips()),
233335
],
234336
),
235337
),
236338
VerticalDivider(color: Colors.grey[400], width: 3),
237-
Expanded(child: _buildPostList('All')),
339+
Expanded(child: buildPostList('All')),
238340
],
239341
);
240342
} else {
@@ -244,24 +346,19 @@ class _PostListPageState extends State<PostListPage> {
244346
visible: !isShowPostDetail,
245347
child: Column(
246348
children: [
247-
_buildPanel(),
248-
ListTile(
249-
title: const Text('TIL (Today I Learned)'),
250-
onTap: () {
251-
// TODO: Implement TIL navigation
252-
},
349+
buildPanel(),
350+
const ListTile(
351+
title: Center(child: Text('Tags')),
253352
),
353+
Padding(
354+
padding: const EdgeInsets.all(8.0),
355+
child: buildTagChips()),
254356
],
255357
),
256358
),
257359
Divider(color: Colors.grey[400], height: 3),
258360
SizedBox(height: 16),
259-
Text('Recent Posts',
260-
style: TextStyle(
261-
fontWeight: FontWeight.bold,
262-
fontSize: 20,
263-
color: Colors.grey[700])),
264-
Expanded(child: _buildPostList('All')),
361+
Expanded(child: buildPostList('All')),
265362
],
266363
);
267364
}

post/markdown_example.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
title: 예제 마크다운 Test...!
33
date: 2024-08-14
44
category: Test
5+
tag: Example
56
slug: example_markdown
67
---
78

0 commit comments

Comments
 (0)