repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Projectflow
stars
latest
clone command
git clone gitlawb://did:key:z6Mkfh4Y...QBEi/projectflowgit clone gitlawb://did:key:z6Mkfh4Y.../projectflowb3cded1async from playground23h ago| #1 | import { useState, useEffect, useRef, useMemo } from "react"; |
| #2 | import { motion, AnimatePresence } from "framer-motion"; |
| #3 | import { |
| #4 | Search, |
| #5 | Command, |
| #6 | Layers, |
| #7 | FolderKanban, |
| #8 | ArrowRight, |
| #9 | Hash, |
| #10 | Plus, |
| #11 | Settings, |
| #12 | LogOut, |
| #13 | LayoutDashboard, |
| #14 | X, |
| #15 | } from "lucide-react"; |
| #16 | import clsx from "clsx"; |
| #17 | import { useStore } from "../store"; |
| #18 | |
| #19 | interface SearchResult { |
| #20 | id: string; |
| #21 | type: "issue" | "project" | "action"; |
| #22 | title: string; |
| #23 | subtitle?: string; |
| #24 | icon: React.ReactNode; |
| #25 | action: () => void; |
| #26 | } |
| #27 | |
| #28 | export function CommandPalette() { |
| #29 | const { |
| #30 | searchOpen, |
| #31 | setSearchOpen, |
| #32 | issues, |
| #33 | projects, |
| #34 | setCurrentIssue, |
| #35 | setCurrentProject, |
| #36 | setActiveView, |
| #37 | createIssue, |
| #38 | currentProject, |
| #39 | logout, |
| #40 | users, |
| #41 | } = useStore(); |
| #42 | |
| #43 | const [query, setQuery] = useState(""); |
| #44 | const [selectedIndex, setSelectedIndex] = useState(0); |
| #45 | const inputRef = useRef<HTMLInputElement>(null); |
| #46 | const listRef = useRef<HTMLDivElement>(null); |
| #47 | |
| #48 | // Keyboard shortcut |
| #49 | useEffect(() => { |
| #50 | const handleKey = (e: KeyboardEvent) => { |
| #51 | if ((e.metaKey || e.ctrlKey) && e.key === "k") { |
| #52 | e.preventDefault(); |
| #53 | setSearchOpen(!searchOpen); |
| #54 | } |
| #55 | }; |
| #56 | window.addEventListener("keydown", handleKey); |
| #57 | return () => window.removeEventListener("keydown", handleKey); |
| #58 | }, [searchOpen, setSearchOpen]); |
| #59 | |
| #60 | // Focus input when opened |
| #61 | useEffect(() => { |
| #62 | if (searchOpen) { |
| #63 | setQuery(""); |
| #64 | setSelectedIndex(0); |
| #65 | setTimeout(() => inputRef.current?.focus(), 50); |
| #66 | } |
| #67 | }, [searchOpen]); |
| #68 | |
| #69 | // Build results |
| #70 | const results = useMemo<SearchResult[]>(() => { |
| #71 | const items: SearchResult[] = []; |
| #72 | const q = query.toLowerCase(); |
| #73 | |
| #74 | // Actions |
| #75 | if (!q || "new issue".includes(q) || "create".includes(q)) { |
| #76 | items.push({ |
| #77 | id: "action-new-issue", |
| #78 | type: "action", |
| #79 | title: "Create new issue", |
| #80 | subtitle: currentProject?.name, |
| #81 | icon: <Plus size={16} />, |
| #82 | action: () => { |
| #83 | if (currentProject) { |
| #84 | createIssue({ |
| #85 | projectId: currentProject.id, |
| #86 | title: q || "New issue", |
| #87 | description: "", |
| #88 | status: "todo", |
| #89 | priority: "none", |
| #90 | type: "task", |
| #91 | assigneeId: null, |
| #92 | sprintId: null, |
| #93 | labels: [], |
| #94 | dueDate: null, |
| #95 | estimate: null, |
| #96 | parentId: null, |
| #97 | createdById: users[0]?.id || "u1", |
| #98 | }); |
| #99 | } |
| #100 | setSearchOpen(false); |
| #101 | }, |
| #102 | }); |
| #103 | } |
| #104 | |
| #105 | if (!q || "dashboard".includes(q)) { |
| #106 | items.push({ |
| #107 | id: "action-dashboard", |
| #108 | type: "action", |
| #109 | title: "Go to Dashboard", |
| #110 | icon: <LayoutDashboard size={16} />, |
| #111 | action: () => { |
| #112 | setActiveView("dashboard"); |
| #113 | setSearchOpen(false); |
| #114 | }, |
| #115 | }); |
| #116 | } |
| #117 | |
| #118 | if (!q || "board".includes(q) || "kanban".includes(q)) { |
| #119 | items.push({ |
| #120 | id: "action-board", |
| #121 | type: "action", |
| #122 | title: "Go to Board", |
| #123 | icon: <Layers size={16} />, |
| #124 | action: () => { |
| #125 | setActiveView("board"); |
| #126 | setSearchOpen(false); |
| #127 | }, |
| #128 | }); |
| #129 | } |
| #130 | |
| #131 | if (!q || "settings".includes(q)) { |
| #132 | items.push({ |
| #133 | id: "action-settings", |
| #134 | type: "action", |
| #135 | title: "Settings", |
| #136 | icon: <Settings size={16} />, |
| #137 | action: () => { |
| #138 | setActiveView("settings"); |
| #139 | setSearchOpen(false); |
| #140 | }, |
| #141 | }); |
| #142 | } |
| #143 | |
| #144 | if (!q || "logout".includes(q) || "sign out".includes(q)) { |
| #145 | items.push({ |
| #146 | id: "action-logout", |
| #147 | type: "action", |
| #148 | title: "Log out", |
| #149 | icon: <LogOut size={16} />, |
| #150 | action: () => { |
| #151 | logout(); |
| #152 | setSearchOpen(false); |
| #153 | }, |
| #154 | }); |
| #155 | } |
| #156 | |
| #157 | // Projects |
| #158 | projects |
| #159 | .filter( |
| #160 | (p) => !q || p.name.toLowerCase().includes(q) |
| #161 | ) |
| #162 | .forEach((p) => { |
| #163 | items.push({ |
| #164 | id: `project-${p.id}`, |
| #165 | type: "project", |
| #166 | title: p.name, |
| #167 | subtitle: p.description, |
| #168 | icon: <span className="text-base">{p.emoji}</span>, |
| #169 | action: () => { |
| #170 | setCurrentProject(p.id); |
| #171 | setActiveView("board"); |
| #172 | setSearchOpen(false); |
| #173 | }, |
| #174 | }); |
| #175 | }); |
| #176 | |
| #177 | // Issues |
| #178 | issues |
| #179 | .filter( |
| #180 | (i) => |
| #181 | !q || |
| #182 | i.title.toLowerCase().includes(q) || |
| #183 | i.identifier.toLowerCase().includes(q) |
| #184 | ) |
| #185 | .slice(0, 15) |
| #186 | .forEach((i) => { |
| #187 | const project = projects.find((p) => p.id === i.projectId); |
| #188 | items.push({ |
| #189 | id: `issue-${i.id}`, |
| #190 | type: "issue", |
| #191 | title: i.title, |
| #192 | subtitle: `${i.identifier} · ${project?.name || ""}`, |
| #193 | icon: <Hash size={16} />, |
| #194 | action: () => { |
| #195 | if (project) { |
| #196 | setCurrentProject(project.id); |
| #197 | } |
| #198 | setCurrentIssue(i); |
| #199 | setSearchOpen(false); |
| #200 | }, |
| #201 | }); |
| #202 | }); |
| #203 | |
| #204 | return items; |
| #205 | }, [ |
| #206 | query, |
| #207 | issues, |
| #208 | projects, |
| #209 | currentProject, |
| #210 | createIssue, |
| #211 | setCurrentIssue, |
| #212 | setCurrentProject, |
| #213 | setActiveView, |
| #214 | setSearchOpen, |
| #215 | logout, |
| #216 | users, |
| #217 | ]); |
| #218 | |
| #219 | // Reset selection when results change |
| #220 | useEffect(() => { |
| #221 | setSelectedIndex(0); |
| #222 | }, [results.length]); |
| #223 | |
| #224 | // Keyboard navigation |
| #225 | const handleKeyDown = (e: React.KeyboardEvent) => { |
| #226 | switch (e.key) { |
| #227 | case "ArrowDown": |
| #228 | e.preventDefault(); |
| #229 | setSelectedIndex((i) => Math.min(i + 1, results.length - 1)); |
| #230 | break; |
| #231 | case "ArrowUp": |
| #232 | e.preventDefault(); |
| #233 | setSelectedIndex((i) => Math.max(i - 1, 0)); |
| #234 | break; |
| #235 | case "Enter": |
| #236 | e.preventDefault(); |
| #237 | if (results[selectedIndex]) { |
| #238 | results[selectedIndex].action(); |
| #239 | } |
| #240 | break; |
| #241 | case "Escape": |
| #242 | setSearchOpen(false); |
| #243 | break; |
| #244 | } |
| #245 | }; |
| #246 | |
| #247 | // Scroll selected into view |
| #248 | useEffect(() => { |
| #249 | if (listRef.current) { |
| #250 | const selected = listRef.current.children[selectedIndex] as HTMLElement; |
| #251 | if (selected) { |
| #252 | selected.scrollIntoView({ block: "nearest" }); |
| #253 | } |
| #254 | } |
| #255 | }, [selectedIndex]); |
| #256 | |
| #257 | return ( |
| #258 | <AnimatePresence> |
| #259 | {searchOpen && ( |
| #260 | <div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"> |
| #261 | <motion.div |
| #262 | initial={{ opacity: 0 }} |
| #263 | animate={{ opacity: 1 }} |
| #264 | exit={{ opacity: 0 }} |
| #265 | transition={{ duration: 0.1 }} |
| #266 | className="absolute inset-0 bg-black/60 backdrop-blur-sm" |
| #267 | onClick={() => setSearchOpen(false)} |
| #268 | /> |
| #269 | |
| #270 | <motion.div |
| #271 | initial={{ opacity: 0, scale: 0.95, y: -10 }} |
| #272 | animate={{ opacity: 1, scale: 1, y: 0 }} |
| #273 | exit={{ opacity: 0, scale: 0.95, y: -10 }} |
| #274 | transition={{ duration: 0.15 }} |
| #275 | className="relative w-full max-w-lg rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 overflow-hidden" |
| #276 | > |
| #277 | {/* Input */} |
| #278 | <div className="flex items-center gap-3 px-4 py-3 border-b border-border"> |
| #279 | <Search size={16} className="text-zinc-500 shrink-0" /> |
| #280 | <input |
| #281 | ref={inputRef} |
| #282 | value={query} |
| #283 | onChange={(e) => setQuery(e.target.value)} |
| #284 | onKeyDown={handleKeyDown} |
| #285 | placeholder="Search issues, projects, or actions..." |
| #286 | className="flex-1 text-sm text-zinc-100 bg-transparent outline-none" |
| #287 | /> |
| #288 | <kbd className="kbd">Esc</kbd> |
| #289 | </div> |
| #290 | |
| #291 | {/* Results */} |
| #292 | <div ref={listRef} className="max-h-80 overflow-y-auto py-2"> |
| #293 | {results.length === 0 ? ( |
| #294 | <div className="px-4 py-8 text-center"> |
| #295 | <p className="text-sm text-zinc-500">No results found</p> |
| #296 | </div> |
| #297 | ) : ( |
| #298 | results.map((result, index) => ( |
| #299 | <button |
| #300 | key={result.id} |
| #301 | onClick={result.action} |
| #302 | onMouseEnter={() => setSelectedIndex(index)} |
| #303 | className={clsx( |
| #304 | "w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors", |
| #305 | index === selectedIndex |
| #306 | ? "bg-surface-3" |
| #307 | : "hover:bg-surface-2" |
| #308 | )} |
| #309 | > |
| #310 | <span |
| #311 | className={clsx( |
| #312 | "shrink-0", |
| #313 | index === selectedIndex |
| #314 | ? "text-accent" |
| #315 | : "text-zinc-500" |
| #316 | )} |
| #317 | > |
| #318 | {result.icon} |
| #319 | </span> |
| #320 | <div className="flex-1 min-w-0"> |
| #321 | <p className="text-sm text-zinc-200 truncate"> |
| #322 | {result.title} |
| #323 | </p> |
| #324 | {result.subtitle && ( |
| #325 | <p className="text-2xs text-zinc-500 truncate"> |
| #326 | {result.subtitle} |
| #327 | </p> |
| #328 | )} |
| #329 | </div> |
| #330 | {index === selectedIndex && ( |
| #331 | <ArrowRight size={14} className="text-zinc-500 shrink-0" /> |
| #332 | )} |
| #333 | </button> |
| #334 | )) |
| #335 | )} |
| #336 | </div> |
| #337 | |
| #338 | {/* Footer */} |
| #339 | <div className="flex items-center gap-4 px-4 py-2.5 border-t border-border text-2xs text-zinc-600"> |
| #340 | <span className="flex items-center gap-1"> |
| #341 | <kbd className="kbd">↑↓</kbd> Navigate |
| #342 | </span> |
| #343 | <span className="flex items-center gap-1"> |
| #344 | <kbd className="kbd">↵</kbd> Select |
| #345 | </span> |
| #346 | <span className="flex items-center gap-1"> |
| #347 | <kbd className="kbd">Esc</kbd> Close |
| #348 | </span> |
| #349 | </div> |
| #350 | </motion.div> |
| #351 | </div> |
| #352 | )} |
| #353 | </AnimatePresence> |
| #354 | ); |
| #355 | } |
| #356 |