This commit is contained in:
2025-06-22 14:30:37 +02:00
parent 15afa15794
commit 309b5a47df
7 changed files with 306 additions and 42 deletions

22
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -1,3 +1,11 @@
[
"welcome"
]
{
"pinned": [
"welcome"
],
"folderEmojis": {
"sdf": "🍉",
"sdfsdf": "🇭🇷",
"asdfasdf": "🌵",
"asdfasdfasdfasdf": "🫡"
}
}

View File

@@ -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) => (
<div
key={folder.path}
className="border rounded-lg p-3 sm:p-4 cursor-pointer hover:bg-gray-50"
onClick={() => setCurrentPath([...currentPath, folder.name])}
>
<div className="flex items-center gap-2">
<span className="text-xl sm:text-2xl">📁</span>
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
.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 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">{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>
))}
);
})}
{/* 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) => (
<div
key={folder.path}
className="border rounded-lg p-3 cursor-pointer hover:bg-gray-50 flex items-center gap-2 justify-center"
onClick={() => setManagePath([...managePath, folder.name])}
>
<span className="text-xl sm:text-2xl">📁</span>
<span className="font-semibold text-base sm:text-lg">{folder.name}</span>
</div>
))}
{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 justify-between relative"
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>
{/* Posts (pinned first) */}
<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) {
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);

View File

@@ -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));
}

View File

@@ -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>
))}