noslop 0.1.0
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/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +281 -0
- package/dist/lib/content.d.ts +143 -0
- package/dist/lib/content.js +463 -0
- package/dist/lib/content.test.d.ts +1 -0
- package/dist/lib/content.test.js +746 -0
- package/dist/lib/templates.d.ts +35 -0
- package/dist/lib/templates.js +264 -0
- package/dist/lib/templates.test.d.ts +1 -0
- package/dist/lib/templates.test.js +110 -0
- package/dist/tui.d.ts +1 -0
- package/dist/tui.js +372 -0
- package/package.json +84 -0
package/dist/tui.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
3
|
+
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
4
|
+
import Link from 'ink-link';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import { getContentDirs, parseContent, updateStatus as updateStatusLib, moveToPosts as moveToPostsLib, moveToDrafts, addPublishedUrl, deleteDraft, } from './lib/content.js';
|
|
7
|
+
/** Month abbreviations for date display */
|
|
8
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
9
|
+
/**
|
|
10
|
+
* Get a date key (YYYY-MM-DD) for a given day offset from today
|
|
11
|
+
* @param offset - Number of days from today (0 = today, 1 = tomorrow, -1 = yesterday)
|
|
12
|
+
* @returns Date string in YYYY-MM-DD format
|
|
13
|
+
*/
|
|
14
|
+
function getDateKey(offset) {
|
|
15
|
+
const date = new Date();
|
|
16
|
+
date.setDate(date.getDate() + offset);
|
|
17
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Get a display-friendly date string for a given day offset
|
|
21
|
+
* @param offset - Number of days from today
|
|
22
|
+
* @returns Date string like "Jan 15"
|
|
23
|
+
*/
|
|
24
|
+
function getDateDisplay(offset) {
|
|
25
|
+
const date = new Date();
|
|
26
|
+
date.setDate(date.getDate() + offset);
|
|
27
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Get color for day name in calendar based on selection/today state
|
|
31
|
+
* @param isSelected - Whether the day is currently selected
|
|
32
|
+
* @param isToday - Whether the day is today
|
|
33
|
+
* @returns Color string for the day name
|
|
34
|
+
*/
|
|
35
|
+
function getDayNameColor(isSelected, isToday) {
|
|
36
|
+
if (isSelected) {
|
|
37
|
+
return 'yellow';
|
|
38
|
+
}
|
|
39
|
+
if (isToday) {
|
|
40
|
+
return 'green';
|
|
41
|
+
}
|
|
42
|
+
return 'white';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Get color for calendar item based on selection/posted state
|
|
46
|
+
* @param isItemSelected - Whether the item is currently selected
|
|
47
|
+
* @param isPosted - Whether the item has been posted
|
|
48
|
+
* @returns Color string for the calendar item
|
|
49
|
+
*/
|
|
50
|
+
function getCalendarItemColor(isItemSelected, isPosted) {
|
|
51
|
+
if (isItemSelected) {
|
|
52
|
+
return 'black';
|
|
53
|
+
}
|
|
54
|
+
if (isPosted) {
|
|
55
|
+
return 'green';
|
|
56
|
+
}
|
|
57
|
+
return 'cyan';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Header component displaying the app title
|
|
61
|
+
* @param props.width - Terminal width for centering
|
|
62
|
+
*/
|
|
63
|
+
function Header({ width }) {
|
|
64
|
+
return (_jsx(Box, { width: width, justifyContent: "center", paddingY: 1, children: _jsx(Text, { bold: true, color: "magenta", children: "\u2726 NOSLOP CONTENT MANAGER \u2726" }) }));
|
|
65
|
+
}
|
|
66
|
+
// Tabs
|
|
67
|
+
function Tabs({ active, width, scheduleMode, currentDateDisplay, draftsCount, postsCount, }) {
|
|
68
|
+
const draftsCountStr = draftsCount === 0 ? 'NO' : String(draftsCount);
|
|
69
|
+
const postsCountStr = postsCount === 0 ? 'NO' : String(postsCount);
|
|
70
|
+
const draftsLabel = scheduleMode
|
|
71
|
+
? ` ${draftsCountStr} DRAFTS on ${currentDateDisplay} `
|
|
72
|
+
: ` ${draftsCountStr} DRAFTS `;
|
|
73
|
+
const postsLabel = scheduleMode
|
|
74
|
+
? ` ${postsCountStr} POSTS on ${currentDateDisplay} `
|
|
75
|
+
: ` ${postsCountStr} POSTS `;
|
|
76
|
+
return (_jsx(Box, { width: width, borderStyle: "round", borderColor: "magenta", children: _jsxs(Box, { paddingX: 2, children: [_jsx(Text, { backgroundColor: active === 'drafts' ? 'yellow' : undefined, color: active === 'drafts' ? 'black' : 'gray', children: draftsLabel }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { backgroundColor: active === 'posts' ? 'green' : undefined, color: active === 'posts' ? 'black' : 'gray', children: postsLabel }), _jsxs(Text, { color: "gray", children: [" Tab \u2191\u2193 s", scheduleMode ? ' ←→' : '', " q"] })] }) }));
|
|
77
|
+
}
|
|
78
|
+
// Drafts List
|
|
79
|
+
function DraftsList({ drafts, selectedIndex, height, width, }) {
|
|
80
|
+
const ready = drafts.filter(d => d.status === 'ready');
|
|
81
|
+
const inProgress = drafts.filter(d => d.status !== 'ready');
|
|
82
|
+
const titleWidth = width - 16;
|
|
83
|
+
const visibleCount = height - 3;
|
|
84
|
+
const scrollOffset = Math.max(0, Math.min(selectedIndex - Math.floor(visibleCount / 2), drafts.length - visibleCount));
|
|
85
|
+
const visibleDrafts = drafts.slice(scrollOffset, scrollOffset + visibleCount);
|
|
86
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", height: height, width: width, children: [_jsxs(Box, { paddingX: 1, children: [_jsxs(Text, { bold: true, color: "green", children: ["READY", ' '] }), _jsxs(Text, { color: "gray", children: ["(", ready.length, ")"] }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsxs(Text, { bold: true, color: "yellow", children: ["WIP", ' '] }), _jsxs(Text, { color: "gray", children: ["(", inProgress.length, ")"] }), drafts.length > visibleCount && (_jsxs(Text, { color: "gray", children: [' ', "[", scrollOffset + 1, "-", Math.min(scrollOffset + visibleCount, drafts.length), "]"] }))] }), drafts.length === 0 ? (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", children: "(empty)" }) })) : (visibleDrafts.map((item, i) => {
|
|
87
|
+
const actualIndex = scrollOffset + i;
|
|
88
|
+
const selected = actualIndex === selectedIndex;
|
|
89
|
+
const hasMedia = item.media && item.media !== 'None';
|
|
90
|
+
const idNum = item.folder.match(/^(\d{3})/)?.[1] || '---';
|
|
91
|
+
const title = item.title.length > titleWidth
|
|
92
|
+
? `${item.title.slice(0, titleWidth - 3)}...`
|
|
93
|
+
: item.title;
|
|
94
|
+
const isReady = item.status === 'ready';
|
|
95
|
+
return (_jsxs(Box, { paddingX: 1, width: width - 2, children: [_jsx(Text, { color: selected ? 'yellow' : 'gray', children: selected ? '> ' : ' ' }), _jsx(Text, { color: isReady ? 'green' : 'yellow', children: isReady ? '✓ ' : '○ ' }), _jsxs(Text, { color: selected ? 'white' : 'gray', children: [idNum, " "] }), _jsx(Text, { color: "magenta", children: hasMedia ? '+ ' : ' ' }), _jsx(Text, { color: selected ? 'white' : 'gray', wrap: "truncate", children: title })] }, item.id));
|
|
96
|
+
}))] }));
|
|
97
|
+
}
|
|
98
|
+
// Posts List
|
|
99
|
+
function PostsList({ posts, selectedIndex, height, width, }) {
|
|
100
|
+
const titleWidth = width - 16;
|
|
101
|
+
const visibleCount = height - 3;
|
|
102
|
+
const scrollOffset = Math.max(0, Math.min(selectedIndex - Math.floor(visibleCount / 2), posts.length - visibleCount));
|
|
103
|
+
const visiblePosts = posts.slice(scrollOffset, scrollOffset + visibleCount);
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", height: height, width: width, children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "POSTS" }), posts.length > visibleCount && (_jsxs(Text, { color: "gray", children: [' ', "(", scrollOffset + 1, "-", Math.min(scrollOffset + visibleCount, posts.length), "/", posts.length, ")"] }))] }), posts.length === 0 ? (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", children: "(empty)" }) })) : (visiblePosts.map((item, i) => {
|
|
105
|
+
const actualIndex = scrollOffset + i;
|
|
106
|
+
const selected = actualIndex === selectedIndex;
|
|
107
|
+
const hasUrl = item.published && item.published.startsWith('http');
|
|
108
|
+
const hasMedia = item.media && item.media !== 'None';
|
|
109
|
+
const idNum = item.folder.match(/^(\d{3})/)?.[1] || '???';
|
|
110
|
+
const title = item.title.length > titleWidth
|
|
111
|
+
? `${item.title.slice(0, titleWidth - 3)}...`
|
|
112
|
+
: item.title;
|
|
113
|
+
return (_jsxs(Box, { paddingX: 1, width: width - 2, children: [_jsx(Text, { color: selected ? 'yellow' : 'gray', children: selected ? '> ' : ' ' }), _jsxs(Text, { color: selected ? 'white' : 'gray', children: [idNum, " "] }), _jsx(Text, { color: hasUrl ? 'green' : 'gray', children: hasUrl ? '✓' : ' ' }), _jsx(Text, { color: "magenta", children: hasMedia ? '+ ' : ' ' }), _jsx(Text, { color: selected ? 'white' : 'gray', wrap: "truncate", children: title })] }, item.id));
|
|
114
|
+
}))] }));
|
|
115
|
+
}
|
|
116
|
+
// Preview
|
|
117
|
+
function Preview({ item, width, height, isPosts, }) {
|
|
118
|
+
if (!item) {
|
|
119
|
+
return (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", width: width, height: height, padding: 1, children: _jsx(Text, { color: "gray", children: "(select an item)" }) }));
|
|
120
|
+
}
|
|
121
|
+
const contentWidth = width - 4;
|
|
122
|
+
const maxLines = height - 12;
|
|
123
|
+
const hasMedia = item.media && item.media !== 'None';
|
|
124
|
+
const isReady = item.status === 'ready';
|
|
125
|
+
const idNum = item.folder.match(/^(\d{3})/)?.[1] || '---';
|
|
126
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", width: width, height: height, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: idNum }), _jsx(Text, { color: "gray", children: " \u2014 " }), _jsx(Text, { bold: true, color: "white", children: item.title }), hasMedia && _jsx(Text, { color: "magenta", children: " +" }), !isPosts && (_jsxs(Text, { color: isReady ? 'green' : 'yellow', children: [" [", isReady ? 'READY' : 'WIP', "]"] }))] }), _jsx(Text, { color: "gray", children: '─'.repeat(contentWidth) }), _jsx(Box, { flexDirection: "column", height: maxLines, overflow: "hidden", children: _jsx(Text, { color: "white", wrap: "wrap", children: item.post }) }), _jsx(Text, { color: "gray", children: '─'.repeat(contentWidth) }), _jsxs(Box, { flexDirection: "column", children: [hasMedia ? (_jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "magenta", children: "Media: " }), _jsx(Text, { color: "white", children: item.media })] })) : (_jsx(Text, { color: "gray", children: "Media: none" })), (() => {
|
|
127
|
+
if (item.postedAt) {
|
|
128
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "Posted: " }), _jsx(Text, { color: "white", children: item.postedAt })] }));
|
|
129
|
+
}
|
|
130
|
+
if (item.scheduledAt) {
|
|
131
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "Scheduled: " }), _jsx(Text, { color: "white", children: item.scheduledAt })] }));
|
|
132
|
+
}
|
|
133
|
+
return _jsx(Text, { color: "gray", children: "Not scheduled" });
|
|
134
|
+
})(), item.published && item.published.startsWith('http') ? (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u2713 " }), _jsx(Link, { url: item.published, fallback: false, children: _jsx(Text, { color: "cyan", children: item.published.slice(0, contentWidth) }) })] })) : (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u25CB " }), _jsx(Text, { color: "gray", children: "not published" })] }))] }), _jsx(Text, { color: "gray", children: '─'.repeat(contentWidth) }), _jsx(Box, { children: (() => {
|
|
135
|
+
if (isPosts) {
|
|
136
|
+
if (item.published?.startsWith('http')) {
|
|
137
|
+
return _jsx(Text, { color: "gray", children: "Space: unpost" });
|
|
138
|
+
}
|
|
139
|
+
return _jsx(Text, { color: "gray", children: "Enter: add URL \u2502 Space: unpost" });
|
|
140
|
+
}
|
|
141
|
+
return (_jsxs(Text, { color: "gray", children: ["Enter: toggle ready \u2502 Space: post", !isReady ? ' │ Backspace: delete' : ''] }));
|
|
142
|
+
})() })] }));
|
|
143
|
+
}
|
|
144
|
+
// Calendar
|
|
145
|
+
function Calendar({ selectedId, width, posts, drafts, selectedDay, }) {
|
|
146
|
+
const today = new Date();
|
|
147
|
+
const dayWidth = Math.floor((width - 2) / 7);
|
|
148
|
+
const days = [];
|
|
149
|
+
const selectedDate = new Date(today);
|
|
150
|
+
selectedDate.setDate(selectedDate.getDate() + selectedDay);
|
|
151
|
+
const dayOfWeek = selectedDate.getDay();
|
|
152
|
+
const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
153
|
+
const mondayOffset = selectedDay - daysFromMonday;
|
|
154
|
+
const toDateKey = (dateStr) => {
|
|
155
|
+
if (!dateStr) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const [datePart] = dateStr.split(' ');
|
|
159
|
+
return datePart;
|
|
160
|
+
};
|
|
161
|
+
const postedItems = posts
|
|
162
|
+
.filter(p => p.postedAt)
|
|
163
|
+
.map(p => ({
|
|
164
|
+
dateKey: toDateKey(p.postedAt),
|
|
165
|
+
id: p.id,
|
|
166
|
+
title: p.title,
|
|
167
|
+
type: 'posted',
|
|
168
|
+
}));
|
|
169
|
+
const scheduledItems = drafts
|
|
170
|
+
.filter(d => d.scheduledAt)
|
|
171
|
+
.map(d => ({
|
|
172
|
+
dateKey: toDateKey(d.scheduledAt),
|
|
173
|
+
id: d.id,
|
|
174
|
+
title: d.title,
|
|
175
|
+
type: 'scheduled',
|
|
176
|
+
}));
|
|
177
|
+
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
178
|
+
for (let i = 0; i < 7; i++) {
|
|
179
|
+
const dayOffset = mondayOffset + i;
|
|
180
|
+
const date = new Date(today);
|
|
181
|
+
date.setDate(date.getDate() + dayOffset);
|
|
182
|
+
const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
183
|
+
const monthDay = `${MONTHS[date.getMonth()]} ${date.getDate()}`;
|
|
184
|
+
const dayName = dayOffset === 0 ? 'TODAY' : dayNames[i];
|
|
185
|
+
const items = [
|
|
186
|
+
...postedItems.filter(p => p.dateKey === dateKey),
|
|
187
|
+
...scheduledItems.filter(s => s.dateKey === dateKey),
|
|
188
|
+
];
|
|
189
|
+
days.push({ monthDay, dayName, items, dayOffset, isToday: dayOffset === 0 });
|
|
190
|
+
}
|
|
191
|
+
const mondayDate = new Date(today);
|
|
192
|
+
mondayDate.setDate(mondayDate.getDate() + mondayOffset);
|
|
193
|
+
const sundayDate = new Date(today);
|
|
194
|
+
sundayDate.setDate(sundayDate.getDate() + mondayOffset + 6);
|
|
195
|
+
const weekLabel = `${MONTHS[mondayDate.getMonth()]} ${mondayDate.getDate()} - ${MONTHS[sundayDate.getMonth()]} ${sundayDate.getDate()}`;
|
|
196
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { bold: true, color: "gray", children: [' ', "WEEK: ", weekLabel] }), _jsx(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "row", children: days.map((day, i) => {
|
|
197
|
+
const isSelected = day.dayOffset === selectedDay;
|
|
198
|
+
return (_jsxs(Box, { flexDirection: "column", width: dayWidth, borderStyle: "single", borderColor: isSelected ? 'yellow' : 'gray', children: [_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Text, { bold: true, color: getDayNameColor(isSelected, day.isToday), children: day.dayName.slice(0, 3) }), _jsx(Text, { color: "gray", children: day.monthDay })] }), _jsx(Box, { flexDirection: "column", height: 5, children: day.items.length === 0 ? (_jsx(Text, { color: "gray", children: " -" })) : (day.items.slice(0, 3).map((item, j) => {
|
|
199
|
+
const isItemSelected = item.id === selectedId;
|
|
200
|
+
const isPosted = item.type === 'posted';
|
|
201
|
+
return (_jsxs(Text, { backgroundColor: isItemSelected ? 'yellow' : undefined, color: getCalendarItemColor(isItemSelected, isPosted), children: [isPosted ? '✓ ' : '○ ', item.id.slice(0, dayWidth - 4)] }, j));
|
|
202
|
+
})) })] }, i));
|
|
203
|
+
}) })] }));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Main TUI application component
|
|
207
|
+
* Manages state, keyboard input, and file system watching
|
|
208
|
+
* Keyboard shortcuts: Tab (switch tabs), arrows (navigate), Enter (action), q (quit)
|
|
209
|
+
*/
|
|
210
|
+
function App() {
|
|
211
|
+
const { exit } = useApp();
|
|
212
|
+
const { stdout } = useStdout();
|
|
213
|
+
const [tab, setTab] = useState('drafts');
|
|
214
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
215
|
+
const [scheduleMode, setScheduleMode] = useState(false);
|
|
216
|
+
const [selectedDay, setSelectedDay] = useState(0);
|
|
217
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
218
|
+
const [urlInput, setUrlInput] = useState(null);
|
|
219
|
+
const [size, setSize] = useState({ width: stdout?.columns || 80, height: stdout?.rows || 24 });
|
|
220
|
+
const { postsDir, draftsDir } = getContentDirs();
|
|
221
|
+
// Handle terminal resize
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!stdout) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const onResize = () => setSize({ width: stdout.columns, height: stdout.rows });
|
|
227
|
+
stdout.on('resize', onResize);
|
|
228
|
+
return () => {
|
|
229
|
+
stdout.off('resize', onResize);
|
|
230
|
+
};
|
|
231
|
+
}, [stdout]);
|
|
232
|
+
// Watch filesystem for changes
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
const watchers = [];
|
|
235
|
+
const triggerRefresh = () => setRefreshKey(r => r + 1);
|
|
236
|
+
if (fs.existsSync(postsDir)) {
|
|
237
|
+
watchers.push(fs.watch(postsDir, { recursive: true }, triggerRefresh));
|
|
238
|
+
}
|
|
239
|
+
if (fs.existsSync(draftsDir)) {
|
|
240
|
+
watchers.push(fs.watch(draftsDir, { recursive: true }, triggerRefresh));
|
|
241
|
+
}
|
|
242
|
+
return () => watchers.forEach(w => w.close());
|
|
243
|
+
}, [postsDir, draftsDir]);
|
|
244
|
+
const { width, height } = size;
|
|
245
|
+
// Memoize content parsing to avoid re-parsing on every render
|
|
246
|
+
// refreshKey is intentionally included to force re-parse on file system changes
|
|
247
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
248
|
+
const posts = useMemo(() => parseContent(postsDir, 'P'), [postsDir, refreshKey]);
|
|
249
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
250
|
+
const drafts = useMemo(() => parseContent(draftsDir, 'D'), [draftsDir, refreshKey]);
|
|
251
|
+
// Memoize sorted drafts
|
|
252
|
+
const sortedDrafts = useMemo(() => [...drafts].sort((a, b) => {
|
|
253
|
+
const dateA = a.scheduledAt || '9999-99-99';
|
|
254
|
+
const dateB = b.scheduledAt || '9999-99-99';
|
|
255
|
+
return dateA.localeCompare(dateB);
|
|
256
|
+
}), [drafts]);
|
|
257
|
+
const currentDateKey = getDateKey(selectedDay);
|
|
258
|
+
const currentDateDisplay = getDateDisplay(selectedDay);
|
|
259
|
+
const toDateKey = (dateStr) => {
|
|
260
|
+
if (!dateStr) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const [datePart] = dateStr.split(' ');
|
|
264
|
+
return datePart;
|
|
265
|
+
};
|
|
266
|
+
const getFilteredItems = (allItems, isPost) => {
|
|
267
|
+
if (!scheduleMode) {
|
|
268
|
+
return allItems;
|
|
269
|
+
}
|
|
270
|
+
return allItems.filter(item => {
|
|
271
|
+
const dateField = isPost ? item.postedAt : item.scheduledAt;
|
|
272
|
+
return toDateKey(dateField) === currentDateKey;
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
const filteredPosts = getFilteredItems(posts, true);
|
|
276
|
+
const filteredDrafts = getFilteredItems(sortedDrafts, false);
|
|
277
|
+
const items = tab === 'posts' ? filteredPosts : filteredDrafts;
|
|
278
|
+
const selected = items[selectedIndex] || null;
|
|
279
|
+
const mainHeight = scheduleMode ? height - 15 : height - 6;
|
|
280
|
+
const sidebarWidth = 45;
|
|
281
|
+
const previewWidth = width - sidebarWidth - 2;
|
|
282
|
+
useInput((input, key) => {
|
|
283
|
+
// URL input mode
|
|
284
|
+
if (urlInput !== null) {
|
|
285
|
+
if (key.escape) {
|
|
286
|
+
setUrlInput(null);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (key.return) {
|
|
290
|
+
if (urlInput.trim() && selected) {
|
|
291
|
+
addPublishedUrl(selected, urlInput.trim());
|
|
292
|
+
setRefreshKey((r) => r + 1);
|
|
293
|
+
}
|
|
294
|
+
setUrlInput(null);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (key.backspace || key.delete) {
|
|
298
|
+
setUrlInput(u => (u ? u.slice(0, -1) : ''));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (input && !key.ctrl && !key.meta) {
|
|
302
|
+
setUrlInput(u => (u || '') + input);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Normal mode
|
|
308
|
+
if (input === 'q') {
|
|
309
|
+
exit();
|
|
310
|
+
}
|
|
311
|
+
if (input === 's') {
|
|
312
|
+
setScheduleMode(m => !m);
|
|
313
|
+
setSelectedIndex(0);
|
|
314
|
+
}
|
|
315
|
+
if (key.tab) {
|
|
316
|
+
setTab(t => (t === 'posts' ? 'drafts' : 'posts'));
|
|
317
|
+
setSelectedIndex(0);
|
|
318
|
+
}
|
|
319
|
+
if (key.upArrow) {
|
|
320
|
+
setSelectedIndex(i => Math.max(0, i - 1));
|
|
321
|
+
}
|
|
322
|
+
if (key.downArrow) {
|
|
323
|
+
setSelectedIndex(i => Math.min(items.length - 1, i + 1));
|
|
324
|
+
}
|
|
325
|
+
if (scheduleMode && key.leftArrow) {
|
|
326
|
+
setSelectedDay(d => d - 1);
|
|
327
|
+
setSelectedIndex(0);
|
|
328
|
+
}
|
|
329
|
+
if (scheduleMode && key.rightArrow) {
|
|
330
|
+
setSelectedDay(d => d + 1);
|
|
331
|
+
setSelectedIndex(0);
|
|
332
|
+
}
|
|
333
|
+
// Enter: toggle ready/draft status for drafts, or add URL for posts
|
|
334
|
+
if (key.return && selected) {
|
|
335
|
+
if (tab === 'drafts') {
|
|
336
|
+
const newStatus = selected.status === 'ready' ? 'draft' : 'ready';
|
|
337
|
+
updateStatusLib(selected, newStatus);
|
|
338
|
+
setRefreshKey((r) => r + 1);
|
|
339
|
+
}
|
|
340
|
+
else if (tab === 'posts' && !selected.published?.startsWith('http')) {
|
|
341
|
+
setUrlInput('');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Space: move draft to posts, or unpost post
|
|
345
|
+
if (input === ' ' && selected) {
|
|
346
|
+
if (tab === 'drafts') {
|
|
347
|
+
moveToPostsLib(selected);
|
|
348
|
+
setTab('posts');
|
|
349
|
+
setSelectedIndex(0);
|
|
350
|
+
setRefreshKey((r) => r + 1);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
moveToDrafts(selected);
|
|
354
|
+
setSelectedIndex(0);
|
|
355
|
+
setRefreshKey((r) => r + 1);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Backspace/Delete: delete in-progress draft
|
|
359
|
+
if ((key.backspace || key.delete) &&
|
|
360
|
+
tab === 'drafts' &&
|
|
361
|
+
selected &&
|
|
362
|
+
selected.status !== 'ready') {
|
|
363
|
+
deleteDraft(selected);
|
|
364
|
+
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
|
365
|
+
setRefreshKey((r) => r + 1);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Header, { width: width }), _jsx(Tabs, { active: tab, width: width, scheduleMode: scheduleMode, currentDateDisplay: currentDateDisplay, draftsCount: filteredDrafts.length, postsCount: filteredPosts.length }), _jsxs(Box, { flexDirection: "row", height: mainHeight, children: [tab === 'drafts' ? (_jsx(DraftsList, { drafts: filteredDrafts, selectedIndex: selectedIndex, height: mainHeight, width: sidebarWidth })) : (_jsx(PostsList, { posts: filteredPosts, selectedIndex: selectedIndex, height: mainHeight, width: sidebarWidth })), _jsx(Preview, { item: selected, width: previewWidth, height: mainHeight, isPosts: tab === 'posts' })] }), scheduleMode && (_jsx(Calendar, { selectedId: selected?.id, width: width, posts: posts, drafts: sortedDrafts, selectedDay: selectedDay })), urlInput !== null && (_jsxs(Box, { borderStyle: "round", borderColor: "green", paddingX: 2, marginTop: 1, children: [_jsxs(Text, { color: "green", bold: true, children: ["Paste URL:", ' '] }), _jsx(Text, { color: "white", children: urlInput }), _jsx(Text, { color: "cyan", children: "\u258C" }), _jsx(Text, { color: "gray", children: " (Enter to confirm, Esc to cancel)" })] }))] }));
|
|
369
|
+
}
|
|
370
|
+
export function renderTUI() {
|
|
371
|
+
render(_jsx(App, {}));
|
|
372
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "noslop",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for AI assistants to manage social media posts as markdown files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"noslop": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"start": "tsx src/index.ts",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"test:coverage": "vitest run --coverage",
|
|
28
|
+
"lint": "eslint src/",
|
|
29
|
+
"lint:fix": "eslint src/ --fix",
|
|
30
|
+
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
31
|
+
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test",
|
|
34
|
+
"clean": "rm -rf dist",
|
|
35
|
+
"prepublishOnly": "npm run build",
|
|
36
|
+
"prepare": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"commander": "^12.0.0",
|
|
40
|
+
"ink": "^5.1.0",
|
|
41
|
+
"ink-link": "^4.1.0",
|
|
42
|
+
"react": "^18.3.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^9.0.0",
|
|
46
|
+
"@types/node": "^20.0.0",
|
|
47
|
+
"@types/react": "^18.2.0",
|
|
48
|
+
"@vitest/coverage-v8": "^3.0.0",
|
|
49
|
+
"eslint": "^9.0.0",
|
|
50
|
+
"eslint-plugin-react": "^7.37.0",
|
|
51
|
+
"eslint-plugin-react-hooks": "^5.0.0",
|
|
52
|
+
"prettier": "^3.4.0",
|
|
53
|
+
"tsx": "^4.7.0",
|
|
54
|
+
"typescript": "^5.0.0",
|
|
55
|
+
"typescript-eslint": "^8.0.0",
|
|
56
|
+
"vitest": "^3.0.0"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=18.0.0"
|
|
60
|
+
},
|
|
61
|
+
"keywords": [
|
|
62
|
+
"cli",
|
|
63
|
+
"content",
|
|
64
|
+
"social-media",
|
|
65
|
+
"tui",
|
|
66
|
+
"ink",
|
|
67
|
+
"workflow",
|
|
68
|
+
"terminal",
|
|
69
|
+
"twitter",
|
|
70
|
+
"x",
|
|
71
|
+
"content-management",
|
|
72
|
+
"scheduling"
|
|
73
|
+
],
|
|
74
|
+
"author": "Ruben",
|
|
75
|
+
"license": "MIT",
|
|
76
|
+
"repository": {
|
|
77
|
+
"type": "git",
|
|
78
|
+
"url": "git+https://github.com/rubenartus/noslop.git"
|
|
79
|
+
},
|
|
80
|
+
"bugs": {
|
|
81
|
+
"url": "https://github.com/rubenartus/noslop/issues"
|
|
82
|
+
},
|
|
83
|
+
"homepage": "https://github.com/rubenartus/noslop#readme"
|
|
84
|
+
}
|