const LS_KEY = "pinboard.v1"; function saveToStorage(data) { try { localStorage.setItem(LS_KEY, JSON.stringify(data)); } catch (e) { console.error("Save error", e); } } function loadFromStorage() { try { const raw = localStorage.getItem(LS_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { console.error("Load error", e); return null; } } function uid(prefix = "id") { return `${prefix}_${Math.random().toString(36).slice(2, 9)}_${Date.now()}`; } function fileToDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); } // ---------- Main App ---------- export default function App() { const [boards, setBoards] = useState(() => { const saved = loadFromStorage(); return saved?.boards || [{ id: "default", name: "All Pins" }]; }); const [pins, setPins] = useState(() => loadFromStorage()?.pins || []); const [activeBoard, setActiveBoard] = useState("default"); const [query, setQuery] = useState(""); const [tagFilter, setTagFilter] = useState(""); const [modalPin, setModalPin] = useState(null); const [showAddPin, setShowAddPin] = useState(false); const [showNewBoard, setShowNewBoard] = useState(false); // Persist useEffect(() => { saveToStorage({ boards, pins }); }, [boards, pins]); const allTags = useMemo(() => { const s = new Set(); pins.forEach((p) => p.tags?.forEach((t) => s.add(t))); return Array.from(s).sort(); }, [pins]); const filteredPins = useMemo(() => { return pins .filter((p) => (activeBoard === "default" ? true : p.boardId === activeBoard)) .filter((p) => query ? (p.title + " " + (p.notes || "") + " " + (p.tags || []).join(" ")) .toLowerCase() .includes(query.toLowerCase()) : true ) .filter((p) => (tagFilter ? p.tags?.includes(tagFilter) : true)) .sort((a, b) => b.createdAt - a.createdAt); }, [pins, activeBoard, query, tagFilter]); function addBoard(name) { const newBoard = { id: uid("board"), name: name.trim() || "Untitled" }; setBoards((b) => [...b, newBoard]); setActiveBoard(newBoard.id); setShowNewBoard(false); } async function addPinsFromFiles(files, meta = {}) { const list = Array.from(files).filter((f) => f.type.startsWith("image/")); const newItems = []; for (const file of list) { const dataUrl = await fileToDataUrl(file); const pin = { id: uid("pin"), title: meta.title || file.name.replace(/\.[^.]+$/, ""), notes: meta.notes || "", tags: meta.tags || [], boardId: meta.boardId || activeBoard || "default", image: dataUrl, // Data URL (base64) createdAt: Date.now(), }; newItems.push(pin); } setPins((prev) => [...newItems, ...prev]); setShowAddPin(false); } function deletePin(id) { setPins((prev) => prev.filter((p) => p.id !== id)); setModalPin(null); } function updatePin(updated) { setPins((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); } function movePinToBoard(pin, boardId) { updatePin({ ...pin, boardId }); } // Drag & drop uploader const dropRef = useRef(null); useEffect(() => { const el = dropRef.current; if (!el) return; const prevent = (e) => { e.preventDefault(); e.stopPropagation(); }; const onDrop = async (e) => { prevent(e); if (e.dataTransfer.files?.length) { await addPinsFromFiles(e.dataTransfer.files, {}); } }; ["dragenter", "dragover", "dragleave", "drop"].forEach((ev) => el.addEventListener(ev, prevent)); el.addEventListener("drop", onDrop); return () => { ["dragenter", "dragover", "dragleave", "drop"].forEach((ev) => el.removeEventListener(ev, prevent)); el.removeEventListener("drop", onDrop); }; }, []); return (
No pins yet. Add a few images to get started.
Drag and drop images here, or
setFiles(Array.from(e.target.files || []))} /> {files.length > 0 && (