Merge pull request 'mobile' (#5) from mobile into main
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
Some checks failed
Deploy / build-and-deploy (push) Failing after 1s
Reviewed-on: http://10.0.0.13:3002/rattatwinko/markdownblog/pulls/5 emoji picker working very good very nice
This commit is contained in:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -19,6 +19,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.0.9",
|
||||
"electron": "^29.1.0",
|
||||
"emoji-picker-react": "^4.12.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jsdom": "^24.0.0",
|
||||
@@ -2827,6 +2828,21 @@
|
||||
"integrity": "sha512-q7SQx6mkLy0GTJK9K9OiWeaBMV4XQtBSdf6MJUzDB/H/5tFXfIiX38Lci1Kl6SsgiEhz1SQI1ejEOU5asWEhwQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-picker-react": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.2.tgz",
|
||||
"integrity": "sha512-6PDYZGlhidt+Kc0ay890IU4HLNfIR7/OxPvcNxw+nJ4HQhMKd8pnGnPn4n2vqC/arRFCNWQhgJP8rpsYKsz0GQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flairup": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@@ -3799,6 +3815,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/flairup": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.0.9",
|
||||
"electron": "^29.1.0",
|
||||
"emoji-picker-react": "^4.12.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jsdom": "^24.0.0",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
[
|
||||
{
|
||||
"pinned": [
|
||||
"welcome"
|
||||
]
|
||||
],
|
||||
"folderEmojis": {
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import Link from 'next/link';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import matter from 'gray-matter';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Theme } from 'emoji-picker-react';
|
||||
|
||||
interface Post {
|
||||
slug: string;
|
||||
@@ -28,6 +30,7 @@ interface Folder {
|
||||
name: string;
|
||||
path: string;
|
||||
children: (Post | Folder)[];
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
interface Post {
|
||||
@@ -43,6 +46,8 @@ interface Post {
|
||||
|
||||
type Node = Post | Folder;
|
||||
|
||||
const EmojiPicker = dynamic(() => import('emoji-picker-react'), { ssr: false });
|
||||
|
||||
export default function AdminPage() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -85,12 +90,10 @@ export default function AdminPage() {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const [lastExportChoice, setLastExportChoice] = useState<string | null>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('lastExportChoice');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [lastExportChoice, setLastExportChoice] = useState<string | null>(null);
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState<string | null>(null);
|
||||
const [emojiPickerAnchor, setEmojiPickerAnchor] = useState<HTMLElement | null>(null);
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
@@ -101,8 +104,8 @@ export default function AdminPage() {
|
||||
if (auth === 'true') {
|
||||
setIsAuthenticated(true);
|
||||
loadContent();
|
||||
const interval = setInterval(loadContent, 500);
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
router.push('/admin');
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -639,6 +642,103 @@ export default function AdminPage() {
|
||||
setLastExportChoice(null);
|
||||
};
|
||||
|
||||
// Simple and reliable emoji update handler
|
||||
const handleSetFolderEmoji = async (folderPath: string, emoji: string) => {
|
||||
try {
|
||||
console.log('Setting emoji for folder:', folderPath, 'to:', emoji);
|
||||
|
||||
// Update local state immediately for instant feedback
|
||||
setNodes(prevNodes => {
|
||||
const updateFolderEmoji = (nodes: Node[]): Node[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.type === 'folder') {
|
||||
if (node.path === folderPath) {
|
||||
return { ...node, emoji };
|
||||
} else if (node.children) {
|
||||
return { ...node, children: updateFolderEmoji(node.children) };
|
||||
}
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
return updateFolderEmoji(prevNodes);
|
||||
});
|
||||
|
||||
// Close picker immediately
|
||||
setEmojiPickerOpen(null);
|
||||
setEmojiPickerAnchor(null);
|
||||
|
||||
// Save to JSON file in background
|
||||
try {
|
||||
const pinnedRes = await fetch('/api/admin/posts', { method: 'GET' });
|
||||
const pinnedData = await pinnedRes.json();
|
||||
const folderEmojis = pinnedData.folderEmojis || {};
|
||||
folderEmojis[folderPath] = emoji;
|
||||
|
||||
await fetch('/api/admin/posts', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folderEmojis, pinned: pinnedData.pinned || [] }),
|
||||
});
|
||||
|
||||
console.log('Emoji saved to JSON successfully');
|
||||
} catch (saveError) {
|
||||
console.error('Failed to save emoji to JSON:', saveError);
|
||||
// Don't show error to user since UI is already updated
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error updating folder emoji:', e);
|
||||
alert(`Error updating folder emoji: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
setEmojiPickerOpen(null);
|
||||
setEmojiPickerAnchor(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Add click outside handler to close emoji picker
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (emojiPickerOpen && emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Element)) {
|
||||
console.log('Closing emoji picker due to outside click');
|
||||
setEmojiPickerOpen(null);
|
||||
setEmojiPickerAnchor(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && emojiPickerOpen) {
|
||||
console.log('Closing emoji picker due to escape key');
|
||||
setEmojiPickerOpen(null);
|
||||
setEmojiPickerAnchor(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [emojiPickerOpen]);
|
||||
|
||||
// Add a unique key for each folder to prevent state conflicts
|
||||
const getFolderKey = (folder: Folder, currentPath: string[]) => {
|
||||
return `${currentPath.join('/')}/${folder.name}`;
|
||||
};
|
||||
|
||||
// Cleanup function to close picker
|
||||
const closeEmojiPicker = () => {
|
||||
setEmojiPickerOpen(null);
|
||||
setEmojiPickerAnchor(null);
|
||||
};
|
||||
|
||||
const getEmojiPickerTheme = (): Theme => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return Theme.DARK;
|
||||
}
|
||||
return Theme.LIGHT;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-3 sm:p-8">
|
||||
{pinFeedback && (
|
||||
@@ -965,18 +1065,75 @@ export default function AdminPage() {
|
||||
{/* Folders */}
|
||||
{currentNodes
|
||||
.filter((node): node is Folder => node.type === 'folder')
|
||||
.map((folder) => (
|
||||
.map((folder) => {
|
||||
const folderKey = getFolderKey(folder, currentPath);
|
||||
const isPickerOpen = emojiPickerOpen === folderKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={folder.path}
|
||||
className="border rounded-lg p-3 sm:p-4 cursor-pointer hover:bg-gray-50"
|
||||
className="border rounded-lg p-3 sm:p-4 cursor-pointer hover:bg-gray-50 relative"
|
||||
onClick={() => setCurrentPath([...currentPath, folder.name])}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl sm:text-2xl">📁</span>
|
||||
<span className="text-xl sm:text-2xl">{folder.emoji || '📁'}</span>
|
||||
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 sm:px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm sm:text-base font-medium shadow focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('Opening emoji picker for folder:', folderKey);
|
||||
setEmojiPickerOpen(folderKey);
|
||||
setEmojiPickerAnchor(e.currentTarget);
|
||||
}}
|
||||
aria-label="Change folder emoji"
|
||||
>
|
||||
😀 Emoji
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Emoji Picker - positioned outside the button to avoid conflicts */}
|
||||
{isPickerOpen && (
|
||||
<div
|
||||
ref={emojiPickerRef}
|
||||
className="fixed z-50 bg-white rounded shadow-lg p-2 emoji-picker-container border"
|
||||
style={{
|
||||
minWidth: 300,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium">Choose emoji for "{folder.name}"</span>
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
onClick={closeEmojiPicker}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<EmojiPicker
|
||||
onEmojiClick={(emojiData) => {
|
||||
console.log('Emoji selected:', emojiData.emoji, 'for folder:', folder.path);
|
||||
handleSetFolderEmoji(folder.path, emojiData.emoji);
|
||||
}}
|
||||
autoFocusSearch
|
||||
width={300}
|
||||
height={350}
|
||||
theme={getEmojiPickerTheme()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Posts: pinned first, then unpinned */}
|
||||
{(() => {
|
||||
@@ -1052,16 +1209,73 @@ export default function AdminPage() {
|
||||
</div>
|
||||
{/* Folders */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{manageNodes.filter((n) => n.type === 'folder').map((folder: any) => (
|
||||
{manageNodes.filter((n) => n.type === 'folder').map((folder: any) => {
|
||||
const folderKey = getFolderKey(folder, managePath);
|
||||
const isPickerOpen = emojiPickerOpen === folderKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={folder.path}
|
||||
className="border rounded-lg p-3 cursor-pointer hover:bg-gray-50 flex items-center gap-2 justify-center"
|
||||
className="border rounded-lg p-3 cursor-pointer hover:bg-gray-50 flex items-center justify-between relative"
|
||||
onClick={() => setManagePath([...managePath, folder.name])}
|
||||
>
|
||||
<span className="text-xl sm:text-2xl">📁</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl sm:text-2xl">{folder.emoji || '📁'}</span>
|
||||
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="px-3 sm:px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm sm:text-base font-medium shadow focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('Opening emoji picker for folder:', folderKey);
|
||||
setEmojiPickerOpen(folderKey);
|
||||
setEmojiPickerAnchor(e.currentTarget);
|
||||
}}
|
||||
aria-label="Change folder emoji"
|
||||
>
|
||||
😀 Emoji
|
||||
</button>
|
||||
|
||||
{/* Emoji Picker - positioned outside the button to avoid conflicts */}
|
||||
{isPickerOpen && (
|
||||
<div
|
||||
ref={emojiPickerRef}
|
||||
className="fixed z-50 bg-white rounded shadow-lg p-2 emoji-picker-container border"
|
||||
style={{
|
||||
minWidth: 300,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium">Choose emoji for "{folder.name}"</span>
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
onClick={closeEmojiPicker}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<EmojiPicker
|
||||
onEmojiClick={(emojiData) => {
|
||||
console.log('Emoji selected:', emojiData.emoji, 'for folder:', folder.path);
|
||||
handleSetFolderEmoji(folder.path, emojiData.emoji);
|
||||
}}
|
||||
autoFocusSearch
|
||||
width={300}
|
||||
height={350}
|
||||
theme={getEmojiPickerTheme()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Posts (pinned first) */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -47,15 +47,29 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// Return the current pinned.json object
|
||||
try {
|
||||
const pinnedPath = path.join(postsDirectory, 'pinned.json');
|
||||
let pinnedData = { pinned: [], folderEmojis: {} };
|
||||
if (fs.existsSync(pinnedPath)) {
|
||||
pinnedData = JSON.parse(fs.readFileSync(pinnedPath, 'utf8'));
|
||||
}
|
||||
return NextResponse.json(pinnedData);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Error reading pinned.json' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { pinned } = body; // expects an array of slugs
|
||||
if (!Array.isArray(pinned)) {
|
||||
return NextResponse.json({ error: 'Invalid pinned data' }, { status: 400 });
|
||||
const { pinned, folderEmojis } = body; // expects pinned (array) and folderEmojis (object)
|
||||
if (!Array.isArray(pinned) || typeof folderEmojis !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid pinned or folderEmojis data' }, { status: 400 });
|
||||
}
|
||||
const pinnedPath = path.join(postsDirectory, 'pinned.json');
|
||||
fs.writeFileSync(pinnedPath, JSON.stringify(pinned, null, 2), 'utf8');
|
||||
fs.writeFileSync(pinnedPath, JSON.stringify({ pinned, folderEmojis }, null, 2), 'utf8');
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error updating pinned.json:', error);
|
||||
|
||||
@@ -13,12 +13,15 @@ import { getPostsDirectory } from '@/lib/postsDirectory';
|
||||
const postsDirectory = getPostsDirectory();
|
||||
|
||||
const pinnedPath = path.join(postsDirectory, 'pinned.json');
|
||||
let pinnedSlugs: string[] = [];
|
||||
let pinnedData: { pinned: string[]; folderEmojis: Record<string, string> } = { pinned: [], folderEmojis: {} };
|
||||
if (fs.existsSync(pinnedPath)) {
|
||||
try {
|
||||
pinnedSlugs = JSON.parse(fs.readFileSync(pinnedPath, 'utf8'));
|
||||
const raw = fs.readFileSync(pinnedPath, 'utf8');
|
||||
pinnedData = JSON.parse(raw);
|
||||
if (!pinnedData.pinned) pinnedData.pinned = [];
|
||||
if (!pinnedData.folderEmojis) pinnedData.folderEmojis = {};
|
||||
} catch {
|
||||
pinnedSlugs = [];
|
||||
pinnedData = { pinned: [], folderEmojis: {} };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +105,7 @@ async function getPostByPath(filePath: string, relPath: string) {
|
||||
summary: data.summary,
|
||||
content: processedContent,
|
||||
createdAt: createdAt.toISOString(),
|
||||
pinned: pinnedSlugs.includes(relPath.replace(/\.md$/, '')),
|
||||
pinned: pinnedData.pinned.includes(relPath.replace(/\.md$/, '')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +120,8 @@ async function readPostsDir(dir: string, relDir = ''): Promise<any[]> {
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const children = await readPostsDir(fullPath, relPath);
|
||||
folders.push({ type: 'folder', name: entry.name, path: relPath, children });
|
||||
const emoji = pinnedData.folderEmojis[relPath] || '📁';
|
||||
folders.push({ type: 'folder', name: entry.name, path: relPath, emoji, children });
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
posts.push(await getPostByPath(fullPath, relPath));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Folder {
|
||||
name: string;
|
||||
path: string;
|
||||
children: (Folder | Post)[];
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
type Node = Folder | Post;
|
||||
@@ -210,7 +211,7 @@ export default function Home() {
|
||||
className="sm:border sm:border-gray-200 sm:rounded-lg p-4 sm:p-6 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => setCurrentPath([...currentPath, folder.name])}
|
||||
>
|
||||
<span className="font-semibold text-base sm:text-lg">📁 {folder.name}</span>
|
||||
<span className="font-semibold text-base sm:text-lg">{folder.emoji || '📁'} {folder.name}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user