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