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 } from "react"; |
| #2 | import { motion, AnimatePresence } from "framer-motion"; |
| #3 | import { |
| #4 | LayoutDashboard, |
| #5 | FolderKanban, |
| #6 | Search, |
| #7 | Bell, |
| #8 | Plus, |
| #9 | ChevronLeft, |
| #10 | ChevronRight, |
| #11 | Settings, |
| #12 | LogOut, |
| #13 | Zap, |
| #14 | Users, |
| #15 | BarChart3, |
| #16 | ListTodo, |
| #17 | CalendarDays, |
| #18 | Layers, |
| #19 | Command, |
| #20 | } from "lucide-react"; |
| #21 | import clsx from "clsx"; |
| #22 | import { useStore } from "../store"; |
| #23 | import { Avatar, Tooltip } from "./ui"; |
| #24 | |
| #25 | // Sidebar |
| #26 | export function Sidebar() { |
| #27 | const { |
| #28 | sidebarCollapsed, |
| #29 | toggleSidebar, |
| #30 | projects, |
| #31 | currentProject, |
| #32 | setCurrentProject, |
| #33 | activeView, |
| #34 | setActiveView, |
| #35 | setCurrentIssue, |
| #36 | } = useStore(); |
| #37 | |
| #38 | const navItems = [ |
| #39 | { id: "dashboard", label: "Dashboard", icon: LayoutDashboard }, |
| #40 | { id: "projects", label: "Projects", icon: FolderKanban }, |
| #41 | ]; |
| #42 | |
| #43 | const projectViews = currentProject |
| #44 | ? [ |
| #45 | { id: "board", label: "Board", icon: Layers }, |
| #46 | { id: "backlog", label: "Backlog", icon: ListTodo }, |
| #47 | { id: "sprints", label: "Sprints", icon: CalendarDays }, |
| #48 | { id: "analytics", label: "Analytics", icon: BarChart3 }, |
| #49 | { id: "members", label: "Members", icon: Users }, |
| #50 | ] |
| #51 | : []; |
| #52 | |
| #53 | return ( |
| #54 | <motion.aside |
| #55 | animate={{ width: sidebarCollapsed ? 64 : 260 }} |
| #56 | transition={{ duration: 0.2, ease: "easeInOut" }} |
| #57 | className="h-screen flex flex-col border-r border-border bg-surface-1 shrink-0 overflow-hidden" |
| #58 | > |
| #59 | {/* Logo */} |
| #60 | <div className="h-14 flex items-center px-4 border-b border-border shrink-0"> |
| #61 | <div className="flex items-center gap-2.5 min-w-0"> |
| #62 | <div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shrink-0"> |
| #63 | <Zap size={14} className="text-white" /> |
| #64 | </div> |
| #65 | <AnimatePresence> |
| #66 | {!sidebarCollapsed && ( |
| #67 | <motion.span |
| #68 | initial={{ opacity: 0, width: 0 }} |
| #69 | animate={{ opacity: 1, width: "auto" }} |
| #70 | exit={{ opacity: 0, width: 0 }} |
| #71 | className="text-sm font-semibold text-zinc-100 whitespace-nowrap overflow-hidden" |
| #72 | > |
| #73 | ProjectFlow |
| #74 | </motion.span> |
| #75 | )} |
| #76 | </AnimatePresence> |
| #77 | </div> |
| #78 | </div> |
| #79 | |
| #80 | {/* Navigation */} |
| #81 | <div className="flex-1 overflow-y-auto py-3 px-3 space-y-1"> |
| #82 | {/* Main nav */} |
| #83 | {navItems.map((item) => ( |
| #84 | <Tooltip key={item.id} content={sidebarCollapsed ? item.label : ""}> |
| #85 | <button |
| #86 | onClick={() => { |
| #87 | setActiveView(item.id); |
| #88 | setCurrentIssue(null); |
| #89 | }} |
| #90 | className={clsx( |
| #91 | "sidebar-item w-full", |
| #92 | activeView === item.id && "active" |
| #93 | )} |
| #94 | > |
| #95 | <item.icon size={18} className="shrink-0" /> |
| #96 | <AnimatePresence> |
| #97 | {!sidebarCollapsed && ( |
| #98 | <motion.span |
| #99 | initial={{ opacity: 0 }} |
| #100 | animate={{ opacity: 1 }} |
| #101 | exit={{ opacity: 0 }} |
| #102 | className="text-sm whitespace-nowrap" |
| #103 | > |
| #104 | {item.label} |
| #105 | </motion.span> |
| #106 | )} |
| #107 | </AnimatePresence> |
| #108 | </button> |
| #109 | </Tooltip> |
| #110 | ))} |
| #111 | |
| #112 | {/* Projects section */} |
| #113 | <AnimatePresence> |
| #114 | {!sidebarCollapsed && ( |
| #115 | <motion.div |
| #116 | initial={{ opacity: 0 }} |
| #117 | animate={{ opacity: 1 }} |
| #118 | exit={{ opacity: 0 }} |
| #119 | > |
| #120 | <div className="pt-4 pb-2 px-3"> |
| #121 | <span className="text-2xs font-medium uppercase tracking-wider text-zinc-500"> |
| #122 | Projects |
| #123 | </span> |
| #124 | </div> |
| #125 | {projects.map((project) => ( |
| #126 | <button |
| #127 | key={project.id} |
| #128 | onClick={() => { |
| #129 | setCurrentProject(project.id); |
| #130 | setActiveView("board"); |
| #131 | setCurrentIssue(null); |
| #132 | }} |
| #133 | className={clsx( |
| #134 | "sidebar-item w-full", |
| #135 | currentProject?.id === project.id && "active" |
| #136 | )} |
| #137 | > |
| #138 | <span className="text-base shrink-0">{project.emoji}</span> |
| #139 | <span className="text-sm whitespace-nowrap truncate"> |
| #140 | {project.name} |
| #141 | </span> |
| #142 | </button> |
| #143 | ))} |
| #144 | </motion.div> |
| #145 | )} |
| #146 | </AnimatePresence> |
| #147 | |
| #148 | {/* Project views */} |
| #149 | {currentProject && ( |
| #150 | <AnimatePresence> |
| #151 | {!sidebarCollapsed && ( |
| #152 | <motion.div |
| #153 | initial={{ opacity: 0 }} |
| #154 | animate={{ opacity: 1 }} |
| #155 | exit={{ opacity: 0 }} |
| #156 | > |
| #157 | <div className="pt-4 pb-2 px-3"> |
| #158 | <span className="text-2xs font-medium uppercase tracking-wider text-zinc-500"> |
| #159 | {currentProject.emoji} {currentProject.name} |
| #160 | </span> |
| #161 | </div> |
| #162 | {projectViews.map((view) => ( |
| #163 | <button |
| #164 | key={view.id} |
| #165 | onClick={() => { |
| #166 | setActiveView(view.id); |
| #167 | setCurrentIssue(null); |
| #168 | }} |
| #169 | className={clsx( |
| #170 | "sidebar-item w-full", |
| #171 | activeView === view.id && "active" |
| #172 | )} |
| #173 | > |
| #174 | <view.icon size={16} className="shrink-0" /> |
| #175 | <span className="text-sm whitespace-nowrap"> |
| #176 | {view.label} |
| #177 | </span> |
| #178 | </button> |
| #179 | ))} |
| #180 | </motion.div> |
| #181 | )} |
| #182 | </AnimatePresence> |
| #183 | )} |
| #184 | </div> |
| #185 | |
| #186 | {/* Collapse toggle */} |
| #187 | <div className="p-3 border-t border-border shrink-0"> |
| #188 | <button |
| #189 | onClick={toggleSidebar} |
| #190 | className="sidebar-item w-full justify-center" |
| #191 | > |
| #192 | {sidebarCollapsed ? ( |
| #193 | <ChevronRight size={16} /> |
| #194 | ) : ( |
| #195 | <ChevronLeft size={16} /> |
| #196 | )} |
| #197 | </button> |
| #198 | </div> |
| #199 | </motion.aside> |
| #200 | ); |
| #201 | } |
| #202 | |
| #203 | // Topbar |
| #204 | export function Topbar() { |
| #205 | const { |
| #206 | setSearchOpen, |
| #207 | currentUser, |
| #208 | logout, |
| #209 | notifications, |
| #210 | setActiveView, |
| #211 | currentProject, |
| #212 | activeView, |
| #213 | markAllNotificationsRead, |
| #214 | } = useStore(); |
| #215 | |
| #216 | const [showNotifications, setShowNotifications] = useState(false); |
| #217 | const [showProfile, setShowProfile] = useState(false); |
| #218 | const unreadCount = notifications.filter((n) => !n.read).length; |
| #219 | |
| #220 | // Close dropdowns on outside click |
| #221 | useEffect(() => { |
| #222 | const handleClick = () => { |
| #223 | setShowNotifications(false); |
| #224 | setShowProfile(false); |
| #225 | }; |
| #226 | if (showNotifications || showProfile) { |
| #227 | window.addEventListener("click", handleClick); |
| #228 | return () => window.removeEventListener("click", handleClick); |
| #229 | } |
| #230 | }, [showNotifications, showProfile]); |
| #231 | |
| #232 | const getPageTitle = () => { |
| #233 | const titles: Record<string, string> = { |
| #234 | dashboard: "Dashboard", |
| #235 | projects: "Projects", |
| #236 | board: "Board", |
| #237 | backlog: "Backlog", |
| #238 | sprints: "Sprints", |
| #239 | analytics: "Analytics", |
| #240 | members: "Members", |
| #241 | settings: "Settings", |
| #242 | }; |
| #243 | return titles[activeView] || "Dashboard"; |
| #244 | }; |
| #245 | |
| #246 | return ( |
| #247 | <header className="h-14 flex items-center justify-between px-5 border-b border-border bg-surface-1/80 backdrop-blur-sm shrink-0"> |
| #248 | {/* Left */} |
| #249 | <div className="flex items-center gap-3"> |
| #250 | <h1 className="text-sm font-semibold text-zinc-200"> |
| #251 | {currentProject && activeView !== "dashboard" && activeView !== "projects" |
| #252 | ? `${currentProject.emoji} ${currentProject.name}` |
| #253 | : getPageTitle()} |
| #254 | </h1> |
| #255 | {currentProject && activeView !== "dashboard" && activeView !== "projects" && ( |
| #256 | <span className="text-zinc-600">/</span> |
| #257 | )} |
| #258 | {currentProject && activeView !== "dashboard" && activeView !== "projects" && ( |
| #259 | <span className="text-sm text-zinc-400">{getPageTitle()}</span> |
| #260 | )} |
| #261 | </div> |
| #262 | |
| #263 | {/* Right */} |
| #264 | <div className="flex items-center gap-2"> |
| #265 | {/* Search */} |
| #266 | <button |
| #267 | onClick={() => setSearchOpen(true)} |
| #268 | className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border bg-surface-2 text-zinc-400 hover:text-zinc-200 hover:border-border-strong transition-colors text-sm" |
| #269 | > |
| #270 | <Search size={14} /> |
| #271 | <span className="hidden sm:inline">Search...</span> |
| #272 | <div className="flex items-center gap-0.5 ml-2"> |
| #273 | <kbd className="kbd"> |
| #274 | <Command size={10} /> |
| #275 | </kbd> |
| #276 | <kbd className="kbd">K</kbd> |
| #277 | </div> |
| #278 | </button> |
| #279 | |
| #280 | {/* Quick create */} |
| #281 | <Tooltip content="New issue"> |
| #282 | <button |
| #283 | onClick={() => setActiveView("board")} |
| #284 | className="p-2 rounded-lg text-zinc-400 hover:text-zinc-200 hover:bg-surface-3 transition-colors" |
| #285 | > |
| #286 | <Plus size={18} /> |
| #287 | </button> |
| #288 | </Tooltip> |
| #289 | |
| #290 | {/* Notifications */} |
| #291 | <div className="relative"> |
| #292 | <Tooltip content="Notifications"> |
| #293 | <button |
| #294 | onClick={(e) => { |
| #295 | e.stopPropagation(); |
| #296 | setShowNotifications(!showNotifications); |
| #297 | setShowProfile(false); |
| #298 | }} |
| #299 | className="p-2 rounded-lg text-zinc-400 hover:text-zinc-200 hover:bg-surface-3 transition-colors relative" |
| #300 | > |
| #301 | <Bell size={18} /> |
| #302 | {unreadCount > 0 && ( |
| #303 | <span className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-accent text-[10px] font-medium text-white flex items-center justify-center"> |
| #304 | {unreadCount} |
| #305 | </span> |
| #306 | )} |
| #307 | </button> |
| #308 | </Tooltip> |
| #309 | |
| #310 | <AnimatePresence> |
| #311 | {showNotifications && ( |
| #312 | <motion.div |
| #313 | initial={{ opacity: 0, y: -4, scale: 0.98 }} |
| #314 | animate={{ opacity: 1, y: 0, scale: 1 }} |
| #315 | exit={{ opacity: 0, y: -4, scale: 0.98 }} |
| #316 | transition={{ duration: 0.1 }} |
| #317 | onClick={(e) => e.stopPropagation()} |
| #318 | className="absolute right-0 top-full mt-2 w-80 rounded-xl border border-border bg-surface-2 shadow-xl shadow-black/30 z-50 overflow-hidden" |
| #319 | > |
| #320 | <div className="flex items-center justify-between px-4 py-3 border-b border-border"> |
| #321 | <span className="text-sm font-medium text-zinc-200"> |
| #322 | Notifications |
| #323 | </span> |
| #324 | {unreadCount > 0 && ( |
| #325 | <button |
| #326 | onClick={() => markAllNotificationsRead()} |
| #327 | className="text-2xs text-accent hover:text-accent-hover" |
| #328 | > |
| #329 | Mark all read |
| #330 | </button> |
| #331 | )} |
| #332 | </div> |
| #333 | <div className="max-h-80 overflow-y-auto"> |
| #334 | {notifications.length === 0 ? ( |
| #335 | <div className="p-4 text-center text-sm text-zinc-500"> |
| #336 | No notifications |
| #337 | </div> |
| #338 | ) : ( |
| #339 | notifications.slice(0, 10).map((n) => ( |
| #340 | <div |
| #341 | key={n.id} |
| #342 | className={clsx( |
| #343 | "px-4 py-3 border-b border-border-subtle hover:bg-surface-3 transition-colors cursor-pointer", |
| #344 | !n.read && "bg-accent/5" |
| #345 | )} |
| #346 | > |
| #347 | <p className="text-sm text-zinc-200 leading-snug"> |
| #348 | {n.message} |
| #349 | </p> |
| #350 | <p className="text-2xs text-zinc-500 mt-1"> |
| #351 | {new Date(n.createdAt).toLocaleDateString()} |
| #352 | </p> |
| #353 | </div> |
| #354 | )) |
| #355 | )} |
| #356 | </div> |
| #357 | </motion.div> |
| #358 | )} |
| #359 | </AnimatePresence> |
| #360 | </div> |
| #361 | |
| #362 | {/* Profile */} |
| #363 | <div className="relative"> |
| #364 | <button |
| #365 | onClick={(e) => { |
| #366 | e.stopPropagation(); |
| #367 | setShowProfile(!showProfile); |
| #368 | setShowNotifications(false); |
| #369 | }} |
| #370 | className="flex items-center gap-2 p-1 rounded-lg hover:bg-surface-3 transition-colors" |
| #371 | > |
| #372 | <Avatar |
| #373 | name={currentUser?.name || "User"} |
| #374 | size="sm" |
| #375 | /> |
| #376 | </button> |
| #377 | |
| #378 | <AnimatePresence> |
| #379 | {showProfile && ( |
| #380 | <motion.div |
| #381 | initial={{ opacity: 0, y: -4, scale: 0.98 }} |
| #382 | animate={{ opacity: 1, y: 0, scale: 1 }} |
| #383 | exit={{ opacity: 0, y: -4, scale: 0.98 }} |
| #384 | transition={{ duration: 0.1 }} |
| #385 | onClick={(e) => e.stopPropagation()} |
| #386 | className="absolute right-0 top-full mt-2 w-56 rounded-xl border border-border bg-surface-2 shadow-xl shadow-black/30 z-50 overflow-hidden" |
| #387 | > |
| #388 | <div className="px-4 py-3 border-b border-border"> |
| #389 | <p className="text-sm font-medium text-zinc-200"> |
| #390 | {currentUser?.name} |
| #391 | </p> |
| #392 | <p className="text-2xs text-zinc-500">{currentUser?.email}</p> |
| #393 | </div> |
| #394 | <div className="py-1"> |
| #395 | <button |
| #396 | onClick={() => { |
| #397 | setActiveView("settings"); |
| #398 | setShowProfile(false); |
| #399 | }} |
| #400 | className="w-full flex items-center gap-2 px-4 py-2 text-sm text-zinc-300 hover:bg-surface-3 hover:text-zinc-100 transition-colors" |
| #401 | > |
| #402 | <Settings size={14} /> |
| #403 | Settings |
| #404 | </button> |
| #405 | <button |
| #406 | onClick={() => { |
| #407 | logout(); |
| #408 | setShowProfile(false); |
| #409 | }} |
| #410 | className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-400 hover:bg-red-500/10 transition-colors" |
| #411 | > |
| #412 | <LogOut size={14} /> |
| #413 | Log out |
| #414 | </button> |
| #415 | </div> |
| #416 | </motion.div> |
| #417 | )} |
| #418 | </AnimatePresence> |
| #419 | </div> |
| #420 | </div> |
| #421 | </header> |
| #422 | ); |
| #423 | } |
| #424 | |
| #425 | // Main Layout |
| #426 | export function Layout({ children }: { children: React.ReactNode }) { |
| #427 | return ( |
| #428 | <div className="flex h-screen overflow-hidden"> |
| #429 | <Sidebar /> |
| #430 | <div className="flex-1 flex flex-col min-w-0"> |
| #431 | <Topbar /> |
| #432 | <main className="flex-1 overflow-y-auto">{children}</main> |
| #433 | </div> |
| #434 | </div> |
| #435 | ); |
| #436 | } |
| #437 |