@rmdes/indiekit-endpoint-posts 1.0.0-beta.27 → 1.0.0-beta.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -7
- package/lib/controllers/posts.js +83 -52
- package/lib/utils.js +101 -5
- package/locales/en.json +16 -1
- package/package.json +2 -3
- package/views/posts.njk +114 -1
package/README.md
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
# @rmdes/indiekit-endpoint-posts
|
|
2
2
|
|
|
3
|
-
Post management endpoint for Indiekit. View posts published
|
|
3
|
+
Post management endpoint for Indiekit. View, create, edit, and delete posts published through your Micropub endpoint.
|
|
4
4
|
|
|
5
5
|
## Fork Notice
|
|
6
6
|
|
|
7
|
-
This is a fork of `@indiekit/endpoint-posts` with
|
|
7
|
+
This is a **fork** of `@indiekit/endpoint-posts` with three critical bug fixes:
|
|
8
8
|
|
|
9
|
-
### Bug
|
|
9
|
+
### 1. Syndication Form Bug Fix (CRITICAL)
|
|
10
10
|
|
|
11
|
-
The syndicate
|
|
11
|
+
The syndicate button was using `data.url` for the `source_url` value, but `data` is never defined in the template context. The controller passes `properties`, not `data`. This caused syndication to fail or syndicate the wrong post.
|
|
12
|
+
|
|
13
|
+
**Fixed:** Changed `value: data.url` to `value: properties.url` in `includes/@indiekit-endpoint-posts-syndicate.njk`
|
|
12
14
|
|
|
13
15
|
**PR submitted upstream:** https://github.com/getindiekit/indiekit/pull/828
|
|
14
16
|
|
|
17
|
+
### 2. MongoDB Query Performance Fix
|
|
18
|
+
|
|
19
|
+
The original `getPostProperties()` function queried the Micropub endpoint without any filter (`q=source`), fetching only the first 40 posts and searching through them client-side. **This made posts older than 40 items completely inaccessible**, causing 404 errors when trying to view or edit them.
|
|
20
|
+
|
|
21
|
+
**Fixed:** Now queries the MongoDB database directly by `_id`, which is efficient and works regardless of how many posts exist.
|
|
22
|
+
|
|
23
|
+
### 3. BSON Version Conflict Fix
|
|
24
|
+
|
|
25
|
+
Using `ObjectId` from the MongoDB driver caused BSON version conflicts between Indiekit's MongoDB version and plugin dependencies, resulting in runtime errors.
|
|
26
|
+
|
|
27
|
+
**Fixed:** Use aggregation pipeline with `$toString` to compare `_id` as string, avoiding the need to import `ObjectId`.
|
|
28
|
+
|
|
15
29
|
## Installation
|
|
16
30
|
|
|
17
31
|
```bash
|
|
18
32
|
npm install @rmdes/indiekit-endpoint-posts
|
|
19
33
|
```
|
|
20
34
|
|
|
21
|
-
### Using npm
|
|
35
|
+
### Using npm Overrides (Recommended)
|
|
22
36
|
|
|
23
37
|
Add to your `package.json`:
|
|
24
38
|
|
|
@@ -30,7 +44,36 @@ Add to your `package.json`:
|
|
|
30
44
|
}
|
|
31
45
|
```
|
|
32
46
|
|
|
33
|
-
This replaces the upstream package with this fork without changing your plugin configuration.
|
|
47
|
+
This replaces the upstream package with this fork automatically, without changing your plugin configuration.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
import PostsEndpoint from "@rmdes/indiekit-endpoint-posts";
|
|
53
|
+
|
|
54
|
+
export default {
|
|
55
|
+
plugins: [
|
|
56
|
+
new PostsEndpoint({
|
|
57
|
+
mountPath: "/posts", // Optional, default: "/posts"
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **List posts** with cursor-based pagination
|
|
66
|
+
- **View post details** with status badges and syndication info
|
|
67
|
+
- **Create posts** with dynamic forms based on post type
|
|
68
|
+
- **Edit posts** with all properties and advanced options
|
|
69
|
+
- **Delete/undelete posts** with confirmation
|
|
70
|
+
- **Syndicate posts** to configured targets via button
|
|
71
|
+
- **Multi-language support** (14 languages)
|
|
72
|
+
- **Draft mode** for unpublished posts
|
|
73
|
+
- **Media uploads** (audio, photo, video)
|
|
74
|
+
- **Scheduled publishing** with timezone support
|
|
75
|
+
- **Multi-channel publishing** (if configured)
|
|
76
|
+
- **Geographic coordinates** (ISO 6709 format)
|
|
34
77
|
|
|
35
78
|
## Options
|
|
36
79
|
|
|
@@ -38,6 +81,28 @@ This replaces the upstream package with this fork without changing your plugin c
|
|
|
38
81
|
| :---------- | :------- | :-------------------------------------------------------------- |
|
|
39
82
|
| `mountPath` | `string` | Path to management interface. _Optional_, defaults to `/posts`. |
|
|
40
83
|
|
|
84
|
+
## Differences from Upstream
|
|
85
|
+
|
|
86
|
+
1. **Syndicate form uses correct variable** (`properties.url` instead of `data.url`)
|
|
87
|
+
2. **Posts are queried directly from MongoDB** (not via Micropub endpoint)
|
|
88
|
+
3. **No BSON version dependency** (uses aggregation with string comparison)
|
|
89
|
+
|
|
90
|
+
## Related Plugins
|
|
91
|
+
|
|
92
|
+
- **`@rmdes/indiekit-endpoint-micropub`**: Posts are created/updated/deleted via Micropub
|
|
93
|
+
- **`@rmdes/indiekit-endpoint-syndicate`**: Receives syndicate button POSTs
|
|
94
|
+
- **Post type plugins**: Define fields and validation for each post type (note, article, etc.)
|
|
95
|
+
|
|
96
|
+
## Documentation
|
|
97
|
+
|
|
98
|
+
See [CLAUDE.md](./CLAUDE.md) for comprehensive technical reference, including:
|
|
99
|
+
- Complete architecture overview
|
|
100
|
+
- File-by-file documentation
|
|
101
|
+
- Data flow diagrams
|
|
102
|
+
- Configuration examples
|
|
103
|
+
- Inter-plugin relationships
|
|
104
|
+
- Known gotchas and workarounds
|
|
105
|
+
|
|
41
106
|
## License
|
|
42
107
|
|
|
43
|
-
MIT - Original work by Paul Robert Lloyd, bug
|
|
108
|
+
MIT - Original work by Paul Robert Lloyd, bug fixes by Ricardo Mendes.
|
package/lib/controllers/posts.js
CHANGED
|
@@ -2,75 +2,105 @@ import path from "node:path";
|
|
|
2
2
|
|
|
3
3
|
import { checkScope } from "@indiekit/endpoint-micropub/lib/scope.js";
|
|
4
4
|
import { excerpt } from "@indiekit/util";
|
|
5
|
-
import { mf2tojf2 } from "@paulrobertlloyd/mf2tojf2";
|
|
6
5
|
|
|
7
|
-
import { endpoint } from "../endpoint.js";
|
|
8
6
|
import { statusTypes } from "../status-types.js";
|
|
9
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
getPosts,
|
|
9
|
+
getPostTypeCounts,
|
|
10
|
+
getPostStatusBadges,
|
|
11
|
+
getPostName,
|
|
12
|
+
getPhotoUrl,
|
|
13
|
+
} from "../utils.js";
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
|
-
*
|
|
16
|
+
* Build query string fragment for preserving filters in pagination/links
|
|
17
|
+
* @param {object} params - Current filter state
|
|
18
|
+
* @returns {string} Query string fragment (e.g. "&type=note&sort=oldest")
|
|
19
|
+
*/
|
|
20
|
+
function buildFilterQuery({ type, status, search, sort }) {
|
|
21
|
+
const params = new URLSearchParams();
|
|
22
|
+
if (type && type !== "all") params.set("type", type);
|
|
23
|
+
if (status && status !== "published") params.set("status", status);
|
|
24
|
+
if (search) params.set("search", search);
|
|
25
|
+
if (sort && sort !== "newest") params.set("sort", sort);
|
|
26
|
+
const str = params.toString();
|
|
27
|
+
return str ? `&${str}` : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* List posts with filtering, search, sort, and pagination
|
|
13
32
|
* @type {import("express").RequestHandler}
|
|
14
33
|
*/
|
|
15
34
|
export const postsController = async (request, response, next) => {
|
|
16
35
|
try {
|
|
17
36
|
const { application, publication } = request.app.locals;
|
|
18
|
-
const {
|
|
19
|
-
const { after, before, success } = request.query;
|
|
20
|
-
const limit = Number(request.query.limit) || 12;
|
|
21
|
-
|
|
22
|
-
const micropubUrl = new URL(application.micropubEndpoint);
|
|
23
|
-
micropubUrl.searchParams.append("q", "source");
|
|
24
|
-
micropubUrl.searchParams.append("limit", String(limit));
|
|
37
|
+
const { scope } = request.session;
|
|
25
38
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
// Extract filter/search/sort/pagination from query string
|
|
40
|
+
const postType = request.query.type || "all";
|
|
41
|
+
const status = request.query.status || "published";
|
|
42
|
+
const search = request.query.search || "";
|
|
43
|
+
const sort = request.query.sort || "newest";
|
|
44
|
+
const page = Number(request.query.page) || 0;
|
|
45
|
+
const limit = Number(request.query.limit) || 20;
|
|
46
|
+
const success = request.query.success;
|
|
33
47
|
|
|
34
|
-
|
|
48
|
+
// Build query options (only include non-default values)
|
|
49
|
+
const queryOptions = { page, limit, sort };
|
|
50
|
+
if (postType !== "all") queryOptions.postType = postType;
|
|
51
|
+
if (status !== "published") queryOptions.status = status;
|
|
52
|
+
if (search) queryOptions.search = search;
|
|
35
53
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const items = jf2.children || [jf2];
|
|
54
|
+
// Query MongoDB directly
|
|
55
|
+
const { items, total } = await getPosts(application, queryOptions);
|
|
56
|
+
const typeCounts = await getPostTypeCounts(application);
|
|
40
57
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
// Map items to card format
|
|
59
|
+
const posts = items.map((postData) => {
|
|
60
|
+
const item = { ...postData.properties, uid: postData._id.toString() };
|
|
61
|
+
item.id = item.uid;
|
|
62
|
+
item.icon = item["post-type"];
|
|
63
|
+
item.locale = application.locale;
|
|
64
|
+
item.photo = getPhotoUrl(publication, item);
|
|
65
|
+
item.description = {
|
|
66
|
+
text:
|
|
67
|
+
item.summary ||
|
|
68
|
+
(item.content?.text &&
|
|
69
|
+
excerpt(item.content.text, 30, publication.locale)),
|
|
70
|
+
};
|
|
71
|
+
item.title = getPostName(publication, item);
|
|
72
|
+
item.url = path.join(request.baseUrl, request.path, item.uid);
|
|
73
|
+
item.badges = getPostStatusBadges(item, response);
|
|
74
|
+
return item;
|
|
75
|
+
});
|
|
55
76
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
// Build filter query string for pagination links
|
|
78
|
+
const filterQuery = buildFilterQuery({
|
|
79
|
+
type: postType,
|
|
80
|
+
status,
|
|
81
|
+
search,
|
|
82
|
+
sort,
|
|
83
|
+
});
|
|
59
84
|
|
|
85
|
+
// Offset-based pagination
|
|
86
|
+
const totalPages = Math.ceil(total / limit);
|
|
60
87
|
const cursor = {};
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
cursor.next = {
|
|
64
|
-
href: `?after=${micropubResponse.paging.after}`,
|
|
65
|
-
};
|
|
88
|
+
if (page < totalPages - 1) {
|
|
89
|
+
cursor.next = { href: `?page=${page + 1}${filterQuery}` };
|
|
66
90
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
cursor.previous = {
|
|
70
|
-
href: `?before=${micropubResponse.paging.before}`,
|
|
71
|
-
};
|
|
91
|
+
if (page > 0) {
|
|
92
|
+
cursor.previous = { href: `?page=${page - 1}${filterQuery}` };
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
// Available post types from publication config with counts
|
|
96
|
+
const postTypes = Object.entries(publication.postTypes).map(
|
|
97
|
+
([id, config]) => ({
|
|
98
|
+
id,
|
|
99
|
+
name: config.name,
|
|
100
|
+
count: typeCounts.find((t) => t._id === id)?.count || 0,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
74
104
|
response.render("posts", {
|
|
75
105
|
title: response.locals.__("posts.posts.title"),
|
|
76
106
|
actions: [
|
|
@@ -84,8 +114,9 @@ export const postsController = async (request, response, next) => {
|
|
|
84
114
|
],
|
|
85
115
|
cursor,
|
|
86
116
|
posts,
|
|
87
|
-
|
|
88
|
-
|
|
117
|
+
total,
|
|
118
|
+
filters: { postType, status, search, sort },
|
|
119
|
+
postTypes,
|
|
89
120
|
parentUrl: request.baseUrl + request.path,
|
|
90
121
|
statusTypes,
|
|
91
122
|
success,
|
package/lib/utils.js
CHANGED
|
@@ -3,7 +3,6 @@ import { Buffer } from "node:buffer";
|
|
|
3
3
|
import { sanitise, ISO_6709_RE } from "@indiekit/util";
|
|
4
4
|
import { mf2tojf2 } from "@paulrobertlloyd/mf2tojf2";
|
|
5
5
|
import formatcoords from "formatcoords";
|
|
6
|
-
import { ObjectId } from "mongodb";
|
|
7
6
|
|
|
8
7
|
import { endpoint } from "./endpoint.js";
|
|
9
8
|
import { statusTypes } from "./status-types.js";
|
|
@@ -160,10 +159,19 @@ export const getPostProperties = async (uid, application) => {
|
|
|
160
159
|
return false;
|
|
161
160
|
}
|
|
162
161
|
|
|
163
|
-
// Query
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
// Query using aggregation to match _id as string (avoids BSON version issues)
|
|
163
|
+
const results = await postsCollection
|
|
164
|
+
.aggregate([
|
|
165
|
+
{
|
|
166
|
+
$match: {
|
|
167
|
+
$expr: { $eq: [{ $toString: "$_id" }, uid] },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{ $limit: 1 },
|
|
171
|
+
])
|
|
172
|
+
.toArray();
|
|
173
|
+
|
|
174
|
+
const postData = results[0];
|
|
167
175
|
|
|
168
176
|
if (!postData?.properties) {
|
|
169
177
|
return false;
|
|
@@ -181,6 +189,94 @@ export const getPostProperties = async (uid, application) => {
|
|
|
181
189
|
}
|
|
182
190
|
};
|
|
183
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Query posts collection with filters, search, sort, and pagination
|
|
194
|
+
* @param {object} application - Application config (has collections Map)
|
|
195
|
+
* @param {object} options - Query options
|
|
196
|
+
* @param {string} [options.postType] - Filter by post-type (e.g. "note", "article")
|
|
197
|
+
* @param {string} [options.status] - Filter by post-status ("published", "draft", "deleted")
|
|
198
|
+
* @param {string} [options.search] - Text search (regex on name/content.text)
|
|
199
|
+
* @param {string} [options.sort] - Sort direction ("newest" or "oldest")
|
|
200
|
+
* @param {number} [options.page] - Page number (0-indexed)
|
|
201
|
+
* @param {number} [options.limit] - Items per page
|
|
202
|
+
* @returns {Promise<{items: Array, total: number}>}
|
|
203
|
+
*/
|
|
204
|
+
export const getPosts = async (application, options = {}) => {
|
|
205
|
+
const { postType, status, search, sort = "newest", page = 0, limit = 20 } =
|
|
206
|
+
options;
|
|
207
|
+
|
|
208
|
+
const postsCollection = application?.collections?.get("posts");
|
|
209
|
+
if (!postsCollection) {
|
|
210
|
+
return { items: [], total: 0 };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build MongoDB filter
|
|
214
|
+
const filter = {};
|
|
215
|
+
|
|
216
|
+
if (postType) {
|
|
217
|
+
filter["properties.post-type"] = postType;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (status === "draft") {
|
|
221
|
+
filter["properties.post-status"] = "draft";
|
|
222
|
+
} else if (status === "deleted") {
|
|
223
|
+
filter["properties.deleted"] = true;
|
|
224
|
+
} else if (status === "all") {
|
|
225
|
+
// No status filter — show everything
|
|
226
|
+
} else {
|
|
227
|
+
// Default: "published" — exclude drafts and deleted
|
|
228
|
+
filter["properties.post-status"] = { $ne: "draft" };
|
|
229
|
+
filter["properties.deleted"] = { $ne: true };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (search) {
|
|
233
|
+
// Escape regex special characters in search term
|
|
234
|
+
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
235
|
+
filter.$or = [
|
|
236
|
+
{ "properties.name": { $regex: escaped, $options: "i" } },
|
|
237
|
+
{ "properties.content.text": { $regex: escaped, $options: "i" } },
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sortDirection = sort === "oldest" ? 1 : -1;
|
|
242
|
+
|
|
243
|
+
const [items, total] = await Promise.all([
|
|
244
|
+
postsCollection
|
|
245
|
+
.find(filter)
|
|
246
|
+
.sort({ "properties.published": sortDirection })
|
|
247
|
+
.skip(page * limit)
|
|
248
|
+
.limit(limit)
|
|
249
|
+
.toArray(),
|
|
250
|
+
postsCollection.countDocuments(filter),
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
return { items, total };
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get post counts grouped by post-type
|
|
258
|
+
* @param {object} application - Application config
|
|
259
|
+
* @returns {Promise<Array<{_id: string, count: number}>>}
|
|
260
|
+
*/
|
|
261
|
+
export const getPostTypeCounts = async (application) => {
|
|
262
|
+
const postsCollection = application?.collections?.get("posts");
|
|
263
|
+
if (!postsCollection) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return postsCollection
|
|
268
|
+
.aggregate([
|
|
269
|
+
{
|
|
270
|
+
$group: {
|
|
271
|
+
_id: "$properties.post-type",
|
|
272
|
+
count: { $sum: 1 },
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{ $sort: { count: -1 } },
|
|
276
|
+
])
|
|
277
|
+
.toArray();
|
|
278
|
+
};
|
|
279
|
+
|
|
184
280
|
/**
|
|
185
281
|
* Get post URL from ID
|
|
186
282
|
* @param {string} id - ID
|
package/locales/en.json
CHANGED
|
@@ -119,9 +119,24 @@
|
|
|
119
119
|
"syndicate": "Syndicate post"
|
|
120
120
|
},
|
|
121
121
|
"posts": {
|
|
122
|
-
"title": "
|
|
122
|
+
"title": "Posts",
|
|
123
123
|
"none": "No posts"
|
|
124
124
|
},
|
|
125
|
+
"filter": {
|
|
126
|
+
"type": "Type",
|
|
127
|
+
"status": "Status",
|
|
128
|
+
"all": "All",
|
|
129
|
+
"status_published": "Published",
|
|
130
|
+
"status_draft": "Drafts",
|
|
131
|
+
"status_deleted": "Deleted",
|
|
132
|
+
"status_all": "All",
|
|
133
|
+
"searchPlaceholder": "Search posts\u2026",
|
|
134
|
+
"searchButton": "Search",
|
|
135
|
+
"clear": "Clear",
|
|
136
|
+
"newest": "Newest",
|
|
137
|
+
"oldest": "Oldest",
|
|
138
|
+
"results": "posts"
|
|
139
|
+
},
|
|
125
140
|
"title": "Posts"
|
|
126
141
|
}
|
|
127
142
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-posts",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.29",
|
|
4
4
|
"description": "Post management endpoint for Indiekit with syndicate form fix. View posts published by your Micropub endpoint and publish new posts to it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -47,8 +47,7 @@
|
|
|
47
47
|
"@paulrobertlloyd/mf2tojf2": "^3.0.0",
|
|
48
48
|
"express": "^5.0.0",
|
|
49
49
|
"express-validator": "^7.0.0",
|
|
50
|
-
"formatcoords": "^1.1.3"
|
|
51
|
-
"mongodb": "^6.0.0"
|
|
50
|
+
"formatcoords": "^1.1.3"
|
|
52
51
|
},
|
|
53
52
|
"publishConfig": {
|
|
54
53
|
"access": "public"
|
package/views/posts.njk
CHANGED
|
@@ -1,6 +1,119 @@
|
|
|
1
1
|
{% extends "document.njk" %}
|
|
2
2
|
|
|
3
3
|
{% block content %}
|
|
4
|
+
<style>
|
|
5
|
+
.posts-filter-row {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-wrap: wrap;
|
|
8
|
+
gap: var(--space-xs, 0.5rem);
|
|
9
|
+
align-items: center;
|
|
10
|
+
margin-block-end: var(--space-s, 0.75rem);
|
|
11
|
+
}
|
|
12
|
+
.posts-filter-label {
|
|
13
|
+
font: var(--font-body, 0.875rem/1.5 sans-serif);
|
|
14
|
+
color: var(--color-on-offset, #666);
|
|
15
|
+
}
|
|
16
|
+
.posts-filter-pills {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
gap: 0.25rem;
|
|
20
|
+
}
|
|
21
|
+
.posts-filter-pills a {
|
|
22
|
+
text-decoration: none;
|
|
23
|
+
padding: 0.25rem 0.75rem;
|
|
24
|
+
border-radius: 1rem;
|
|
25
|
+
font-size: 0.875rem;
|
|
26
|
+
color: var(--color-on-offset, #666);
|
|
27
|
+
background: var(--color-offset, #f0f0f0);
|
|
28
|
+
}
|
|
29
|
+
.posts-filter-pills a.active {
|
|
30
|
+
background: var(--color-primary, #0066cc);
|
|
31
|
+
color: var(--color-on-primary, #fff);
|
|
32
|
+
}
|
|
33
|
+
.posts-filter-pills .count {
|
|
34
|
+
font-size: 0.75rem;
|
|
35
|
+
opacity: 0.7;
|
|
36
|
+
}
|
|
37
|
+
.posts-search {
|
|
38
|
+
display: flex;
|
|
39
|
+
gap: var(--space-xs, 0.5rem);
|
|
40
|
+
align-items: center;
|
|
41
|
+
}
|
|
42
|
+
.posts-search input[type="search"] {
|
|
43
|
+
padding: 0.25rem 0.5rem;
|
|
44
|
+
border: 1px solid var(--color-border, #ccc);
|
|
45
|
+
border-radius: var(--border-radius-small, 0.25rem);
|
|
46
|
+
font: inherit;
|
|
47
|
+
}
|
|
48
|
+
.posts-meta {
|
|
49
|
+
font-size: 0.875rem;
|
|
50
|
+
color: var(--color-on-offset, #666);
|
|
51
|
+
margin-block-end: var(--space-s, 0.75rem);
|
|
52
|
+
}
|
|
53
|
+
</style>
|
|
54
|
+
|
|
55
|
+
<div class="posts-filters">
|
|
56
|
+
{# Row 1: Post type pills #}
|
|
57
|
+
<div class="posts-filter-row">
|
|
58
|
+
<span class="posts-filter-label">{{ __("posts.filter.type") }}:</span>
|
|
59
|
+
<div class="posts-filter-pills">
|
|
60
|
+
<a href="?status={{ filters.status }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort={{ filters.sort }}"
|
|
61
|
+
class="{% if filters.postType == 'all' %}active{% endif %}">
|
|
62
|
+
{{ __("posts.filter.all") }}
|
|
63
|
+
</a>
|
|
64
|
+
{% for pt in postTypes %}
|
|
65
|
+
{% if pt.count > 0 %}
|
|
66
|
+
<a href="?type={{ pt.id }}&status={{ filters.status }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort={{ filters.sort }}"
|
|
67
|
+
class="{% if filters.postType == pt.id %}active{% endif %}">
|
|
68
|
+
{{ pt.name }} <span class="count">({{ pt.count }})</span>
|
|
69
|
+
</a>
|
|
70
|
+
{% endif %}
|
|
71
|
+
{% endfor %}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{# Row 2: Status pills #}
|
|
76
|
+
<div class="posts-filter-row">
|
|
77
|
+
<span class="posts-filter-label">{{ __("posts.filter.status") }}:</span>
|
|
78
|
+
<div class="posts-filter-pills">
|
|
79
|
+
{% for s in ["published", "draft", "deleted", "all"] %}
|
|
80
|
+
<a href="?{% if filters.postType != 'all' %}type={{ filters.postType }}&{% endif %}status={{ s }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort={{ filters.sort }}"
|
|
81
|
+
class="{% if filters.status == s %}active{% endif %}">
|
|
82
|
+
{{ __("posts.filter.status_" + s) }}
|
|
83
|
+
</a>
|
|
84
|
+
{% endfor %}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{# Row 3: Search + Sort #}
|
|
89
|
+
<div class="posts-filter-row" style="justify-content: space-between;">
|
|
90
|
+
<form class="posts-search" method="get" action="">
|
|
91
|
+
{% if filters.postType != "all" %}<input type="hidden" name="type" value="{{ filters.postType }}">{% endif %}
|
|
92
|
+
{% if filters.status != "published" %}<input type="hidden" name="status" value="{{ filters.status }}">{% endif %}
|
|
93
|
+
<input type="hidden" name="sort" value="{{ filters.sort }}">
|
|
94
|
+
<input type="search" name="search" value="{{ filters.search }}" placeholder="{{ __('posts.filter.searchPlaceholder') }}">
|
|
95
|
+
<button type="submit" class="button button--small">{{ __("posts.filter.searchButton") }}</button>
|
|
96
|
+
{% if filters.search %}
|
|
97
|
+
<a href="?{% if filters.postType != 'all' %}type={{ filters.postType }}&{% endif %}status={{ filters.status }}&sort={{ filters.sort }}">{{ __("posts.filter.clear") }}</a>
|
|
98
|
+
{% endif %}
|
|
99
|
+
</form>
|
|
100
|
+
<div class="posts-filter-pills">
|
|
101
|
+
<a href="?{% if filters.postType != 'all' %}type={{ filters.postType }}&{% endif %}status={{ filters.status }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort=newest"
|
|
102
|
+
class="{% if filters.sort == 'newest' %}active{% endif %}">{{ __("posts.filter.newest") }}</a>
|
|
103
|
+
<a href="?{% if filters.postType != 'all' %}type={{ filters.postType }}&{% endif %}status={{ filters.status }}{% if filters.search %}&search={{ filters.search }}{% endif %}&sort=oldest"
|
|
104
|
+
class="{% if filters.sort == 'oldest' %}active{% endif %}">{{ __("posts.filter.oldest") }}</a>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{# Results meta #}
|
|
110
|
+
{% if total %}
|
|
111
|
+
<div class="posts-meta">
|
|
112
|
+
<span>{{ total }} {{ __("posts.filter.results") }}{% if filters.search %} for "{{ filters.search }}"{% endif %}</span>
|
|
113
|
+
</div>
|
|
114
|
+
{% endif %}
|
|
115
|
+
|
|
116
|
+
{# Post grid #}
|
|
4
117
|
{%- if posts.length > 0 %}
|
|
5
118
|
{{ cardGrid({
|
|
6
119
|
cardSize: "16rem",
|
|
@@ -10,4 +123,4 @@
|
|
|
10
123
|
{%- else -%}
|
|
11
124
|
{{ prose({ text: __("posts.posts.none") }) }}
|
|
12
125
|
{%- endif %}
|
|
13
|
-
{% endblock %}
|
|
126
|
+
{% endblock %}
|