Merge pull request 'mobile' (#5) from mobile into main
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:
2025-06-22 12:32:25 +00:00
7 changed files with 302 additions and 42 deletions

22
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dompurify": "^3.0.9", "dompurify": "^3.0.9",
"electron": "^29.1.0", "electron": "^29.1.0",
"emoji-picker-react": "^4.12.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
@@ -2827,6 +2828,21 @@
"integrity": "sha512-q7SQx6mkLy0GTJK9K9OiWeaBMV4XQtBSdf6MJUzDB/H/5tFXfIiX38Lci1Kl6SsgiEhz1SQI1ejEOU5asWEhwQ==", "integrity": "sha512-q7SQx6mkLy0GTJK9K9OiWeaBMV4XQtBSdf6MJUzDB/H/5tFXfIiX38Lci1Kl6SsgiEhz1SQI1ejEOU5asWEhwQ==",
"license": "ISC" "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": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -3799,6 +3815,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/flat-cache": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",

View File

@@ -22,6 +22,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dompurify": "^3.0.9", "dompurify": "^3.0.9",
"electron": "^29.1.0", "electron": "^29.1.0",
"emoji-picker-react": "^4.12.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",

View File

@@ -1,3 +1,7 @@
[ {
"welcome" "pinned": [
] "welcome"
],
"folderEmojis": {
}
}

View File

@@ -12,6 +12,8 @@ import Link from 'next/link';
import { marked } from 'marked'; import { marked } from 'marked';
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import matter from 'gray-matter'; import matter from 'gray-matter';
import dynamic from 'next/dynamic';
import { Theme } from 'emoji-picker-react';
interface Post { interface Post {
slug: string; slug: string;
@@ -28,6 +30,7 @@ interface Folder {
name: string; name: string;
path: string; path: string;
children: (Post | Folder)[]; children: (Post | Folder)[];
emoji?: string;
} }
interface Post { interface Post {
@@ -43,6 +46,8 @@ interface Post {
type Node = Post | Folder; type Node = Post | Folder;
const EmojiPicker = dynamic(() => import('emoji-picker-react'), { ssr: false });
export default function AdminPage() { export default function AdminPage() {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -85,12 +90,10 @@ export default function AdminPage() {
} }
return false; return false;
}); });
const [lastExportChoice, setLastExportChoice] = useState<string | null>(() => { const [lastExportChoice, setLastExportChoice] = useState<string | null>(null);
if (typeof window !== 'undefined') { const [emojiPickerOpen, setEmojiPickerOpen] = useState<string | null>(null);
return localStorage.getItem('lastExportChoice'); const [emojiPickerAnchor, setEmojiPickerAnchor] = useState<HTMLElement | null>(null);
} const emojiPickerRef = useRef<HTMLDivElement>(null);
return null;
});
const router = useRouter(); const router = useRouter();
const usernameRef = useRef<HTMLInputElement>(null); const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null); const passwordRef = useRef<HTMLInputElement>(null);
@@ -101,8 +104,8 @@ export default function AdminPage() {
if (auth === 'true') { if (auth === 'true') {
setIsAuthenticated(true); setIsAuthenticated(true);
loadContent(); loadContent();
const interval = setInterval(loadContent, 500); } else {
return () => clearInterval(interval); router.push('/admin');
} }
}, []); }, []);
@@ -639,6 +642,103 @@ export default function AdminPage() {
setLastExportChoice(null); 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 ( return (
<div className="min-h-screen bg-gray-100 p-3 sm:p-8"> <div className="min-h-screen bg-gray-100 p-3 sm:p-8">
{pinFeedback && ( {pinFeedback && (
@@ -965,18 +1065,75 @@ export default function AdminPage() {
{/* Folders */} {/* Folders */}
{currentNodes {currentNodes
.filter((node): node is Folder => node.type === 'folder') .filter((node): node is Folder => node.type === 'folder')
.map((folder) => ( .map((folder) => {
<div const folderKey = getFolderKey(folder, currentPath);
key={folder.path} const isPickerOpen = emojiPickerOpen === folderKey;
className="border rounded-lg p-3 sm:p-4 cursor-pointer hover:bg-gray-50"
onClick={() => setCurrentPath([...currentPath, folder.name])} return (
> <div
<div className="flex items-center gap-2"> key={folder.path}
<span className="text-xl sm:text-2xl">📁</span> className="border rounded-lg p-3 sm:p-4 cursor-pointer hover:bg-gray-50 relative"
<span className="font-semibold text-base sm:text-lg">{folder.name}</span> 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">{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> </div>
</div> );
))} })}
{/* Posts: pinned first, then unpinned */} {/* Posts: pinned first, then unpinned */}
{(() => { {(() => {
@@ -1052,16 +1209,73 @@ export default function AdminPage() {
</div> </div>
{/* Folders */} {/* Folders */}
<div className="space-y-2 mb-4"> <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) => {
<div const folderKey = getFolderKey(folder, managePath);
key={folder.path} const isPickerOpen = emojiPickerOpen === folderKey;
className="border rounded-lg p-3 cursor-pointer hover:bg-gray-50 flex items-center gap-2 justify-center"
onClick={() => setManagePath([...managePath, folder.name])} return (
> <div
<span className="text-xl sm:text-2xl">📁</span> key={folder.path}
<span className="font-semibold text-base sm:text-lg">{folder.name}</span> className="border rounded-lg p-3 cursor-pointer hover:bg-gray-50 flex items-center justify-between relative"
</div> onClick={() => setManagePath([...managePath, folder.name])}
))} >
<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> </div>
{/* Posts (pinned first) */} {/* Posts (pinned first) */}
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -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) { export async function PATCH(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { pinned } = body; // expects an array of slugs const { pinned, folderEmojis } = body; // expects pinned (array) and folderEmojis (object)
if (!Array.isArray(pinned)) { if (!Array.isArray(pinned) || typeof folderEmojis !== 'object') {
return NextResponse.json({ error: 'Invalid pinned data' }, { status: 400 }); return NextResponse.json({ error: 'Invalid pinned or folderEmojis data' }, { status: 400 });
} }
const pinnedPath = path.join(postsDirectory, 'pinned.json'); 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 }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error updating pinned.json:', error); console.error('Error updating pinned.json:', error);

View File

@@ -13,12 +13,15 @@ import { getPostsDirectory } from '@/lib/postsDirectory';
const postsDirectory = getPostsDirectory(); const postsDirectory = getPostsDirectory();
const pinnedPath = path.join(postsDirectory, 'pinned.json'); const pinnedPath = path.join(postsDirectory, 'pinned.json');
let pinnedSlugs: string[] = []; let pinnedData: { pinned: string[]; folderEmojis: Record<string, string> } = { pinned: [], folderEmojis: {} };
if (fs.existsSync(pinnedPath)) { if (fs.existsSync(pinnedPath)) {
try { 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 { } catch {
pinnedSlugs = []; pinnedData = { pinned: [], folderEmojis: {} };
} }
} }
@@ -102,7 +105,7 @@ async function getPostByPath(filePath: string, relPath: string) {
summary: data.summary, summary: data.summary,
content: processedContent, content: processedContent,
createdAt: createdAt.toISOString(), 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()) { if (entry.isDirectory()) {
const children = await readPostsDir(fullPath, relPath); 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')) { } else if (entry.isFile() && entry.name.endsWith('.md')) {
posts.push(await getPostByPath(fullPath, relPath)); posts.push(await getPostByPath(fullPath, relPath));
} }

View File

@@ -22,6 +22,7 @@ interface Folder {
name: string; name: string;
path: string; path: string;
children: (Folder | Post)[]; children: (Folder | Post)[];
emoji?: string;
} }
type Node = Folder | Post; 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" 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])} 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> </div>
))} ))}