a fun bot for the hc slack

feat: add user creation and frontend

dunkirk.sh 699b0688 d6d6fc83

verified
+434 -25
+11
bun.lock
··· 15 15 "react": "^19.1.0", 16 16 "react-dom": "^19.1.0", 17 17 "react-masonry-css": "^1.0.16", 18 + "react-router-dom": "^7.5.1", 18 19 "slack-edge": "^1.3.7", 19 20 "yaml": "^2.7.1", 20 21 }, ··· 198 199 199 200 "colors": ["colors@1.4.0", "", {}, "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="], 200 201 202 + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], 203 + 201 204 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 202 205 203 206 "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], ··· 270 273 271 274 "react-masonry-css": ["react-masonry-css@1.0.16", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ=="], 272 275 276 + "react-router": ["react-router@7.5.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-/jjU3fcYNd2bwz9Q0xt5TwyiyoO8XjSEFXJY4O/lMAlkGTHWuHRAbR9Etik+lSDqMC7A7mz3UlXzgYT6Vl58sA=="], 277 + 278 + "react-router-dom": ["react-router-dom@7.5.1", "", { "dependencies": { "react-router": "7.5.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5DPSPc7ENrt2tlKPq0FtpG80ZbqA9aIKEyqX6hSNJDlol/tr6iqCK4crqdsusmOSSotq6zDsn0y3urX9TuTNmA=="], 279 + 273 280 "require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], 274 281 275 282 "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], ··· 280 287 281 288 "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], 282 289 290 + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], 291 + 283 292 "shell-quote": ["shell-quote@1.8.2", "", {}, "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA=="], 284 293 285 294 "shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="], ··· 295 304 "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], 296 305 297 306 "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 307 + 308 + "turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="], 298 309 299 310 "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], 300 311
+1
package.json
··· 32 32 "react": "^19.1.0", 33 33 "react-dom": "^19.1.0", 34 34 "react-masonry-css": "^1.0.16", 35 + "react-router-dom": "^7.5.1", 35 36 "slack-edge": "^1.3.7", 36 37 "yaml": "^2.7.1" 37 38 }
+3
src/features/api/index.ts
··· 1 1 import { recentTakes } from "./routes/recentTakes"; 2 2 import video from "./routes/video"; 3 3 import { handleApiError } from "../../libs/apiError"; 4 + import { projects } from "./routes/projects"; 4 5 5 6 export { default as video } from "./routes/video"; 6 7 ··· 13 14 return await video(url); 14 15 case "recentTakes": 15 16 return await recentTakes(url); 17 + case "projects": 18 + return await projects(url); 16 19 default: 17 20 return new Response( 18 21 JSON.stringify({ error: "Route not found" }),
+54
src/features/api/routes/projects.ts
··· 1 + import { db } from "../../../libs/db"; 2 + import { users as usersTable } from "../../../libs/schema"; 3 + import { handleApiError } from "../../../libs/apiError"; 4 + import { eq } from "drizzle-orm"; 5 + 6 + export type Project = { 7 + projectName: string; 8 + projectDescription: string; 9 + projectBannerUrl: string; 10 + totalTakesTime: number; 11 + userId: string; 12 + }; 13 + 14 + export async function projects(url: URL): Promise<Response> { 15 + const user = url.searchParams.get("user"); 16 + try { 17 + const projects = await db 18 + .select({ 19 + projectName: usersTable.projectName, 20 + projectDescription: usersTable.projectDescription, 21 + projectBannerUrl: usersTable.projectBannerUrl, 22 + totalTakesTime: usersTable.totalTakesTime, 23 + userId: usersTable.id, 24 + }) 25 + .from(usersTable) 26 + .where(eq(usersTable.id, user ? user : usersTable.id)); 27 + 28 + if (projects.length === 0) { 29 + return new Response( 30 + JSON.stringify({ 31 + projects: [], 32 + }), 33 + { 34 + headers: { 35 + "Content-Type": "application/json", 36 + }, 37 + }, 38 + ); 39 + } 40 + 41 + return new Response( 42 + JSON.stringify({ 43 + projects: user ? projects[0] : projects, 44 + }), 45 + { 46 + headers: { 47 + "Content-Type": "application/json", 48 + }, 49 + }, 50 + ); 51 + } catch (error) { 52 + return handleApiError(error, "projects"); 53 + } 54 + }
+16
src/features/frontend/App.tsx
··· 1 + import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; 2 + import { Projects } from "./pages/Projects"; 3 + import { ProjectTakes } from "./pages/ProjectTakes"; 4 + import { NotFound } from "./pages/404"; 5 + 6 + export function App() { 7 + return ( 8 + <Router> 9 + <Routes> 10 + <Route path="/" element={<Projects />} /> 11 + <Route path="/user/:user" element={<ProjectTakes />} /> 12 + <Route path="*" element={<NotFound />} /> 13 + </Routes> 14 + </Router> 15 + ); 16 + }
+64 -23
src/features/frontend/app.tsx src/features/frontend/pages/ProjectTakes.tsx
··· 1 1 import { useEffect, useState } from "react"; 2 - import { prettyPrintTime } from "../../libs/time"; 3 - import { fetchUserData } from "../../libs/cachet"; 4 - import type { RecentTake } from "../api/routes/recentTakes"; 2 + import { useParams } from "react-router-dom"; 3 + import { prettyPrintTime } from "../../../libs/time"; 4 + import { fetchUserData } from "../../../libs/cachet"; 5 + import type { RecentTake } from "../../api/routes/recentTakes"; 6 + import type { Project } from "../../api/routes/projects"; 5 7 import Masonry from "react-masonry-css"; 6 8 7 - export function App() { 9 + export function ProjectTakes() { 10 + const { user } = useParams(); 8 11 const [takes, setTakes] = useState<RecentTake[]>([]); 9 - 10 12 const [userData, setUserData] = useState<{ 11 13 [key: string]: { displayName: string; imageUrl: string }; 12 14 }>({}); 15 + const [project, setProject] = useState<Project>(); 16 + 17 + useEffect(() => { 18 + async function getTakes() { 19 + try { 20 + const res = await fetch( 21 + `/api/recentTakes?user=${encodeURIComponent(user as string)}`, 22 + ); 23 + if (!res.ok) { 24 + throw new Error(`HTTP error! status: ${res.status}`); 25 + } 26 + const data = await res.json(); 27 + setTakes(data.takes); 28 + } catch (error) { 29 + console.error("Error fetching takes:", error); 30 + setTakes([]); 31 + } 32 + } 33 + 34 + async function getProject() { 35 + try { 36 + const res = await fetch( 37 + `/api/projects?user=${encodeURIComponent(user as string)}`, 38 + ); 39 + if (!res.ok) { 40 + throw new Error(`HTTP error! status: ${res.status}`); 41 + } 42 + const data = await res.json(); 43 + setProject(data.projects); 44 + } catch (error) { 45 + console.error("Error fetching project:", error); 46 + } 47 + } 48 + 49 + getTakes(); 50 + getProject(); 51 + }, [user]); 52 + 13 53 useEffect(() => { 14 54 async function loadUserData() { 15 55 const userIds = takes.map((take) => take.userId); ··· 32 72 loadUserData(); 33 73 }, [takes]); 34 74 35 - useEffect(() => { 36 - async function getTakes() { 37 - try { 38 - const res = await fetch("/api/recentTakes"); 39 - if (!res.ok) { 40 - throw new Error(`HTTP error! status: ${res.status}`); 41 - } 42 - const data = await res.json(); 43 - setTakes(data.takes); 44 - } catch (error) { 45 - console.error("Error fetching takes:", error); 46 - setTakes([]); 47 - } 48 - } 49 - getTakes(); 50 - }, []); 51 - 52 75 const breakpointColumns = { 53 76 default: 4, 54 77 1100: 3, ··· 58 81 59 82 return ( 60 83 <div className="container"> 61 - <h1 className="title">Recent Takes</h1> 84 + <section className="project-header"> 85 + {project?.projectBannerUrl && ( 86 + <img 87 + src={project.projectBannerUrl} 88 + alt="Project banner" 89 + className="project-banner" 90 + style={{ 91 + width: "100%", 92 + height: "200px", 93 + objectFit: "cover", 94 + borderRadius: "12px", 95 + marginBottom: "2rem", 96 + }} 97 + /> 98 + )} 99 + <h1 className="title"> 100 + {project?.projectName || "Recent Takes"} 101 + </h1> 102 + </section> 62 103 {takes.length === 0 ? ( 63 104 <div className="no-takes-message">No takes found</div> 64 105 ) : (
+1 -1
src/features/frontend/index.tsx
··· 1 1 import "./styles.css"; 2 2 import { createRoot } from "react-dom/client"; 3 - import { App } from "./app.tsx"; 3 + import { App } from "./App.tsx"; 4 4 5 5 document.addEventListener("DOMContentLoaded", () => { 6 6 const element = document.getElementById("root");
+33
src/features/frontend/pages/404.tsx
··· 1 + import { useNavigate } from "react-router-dom"; 2 + import { useEffect, useState } from "react"; 3 + 4 + export function NotFound() { 5 + const navigate = useNavigate(); 6 + const [countdown, setCountdown] = useState(5); 7 + 8 + useEffect(() => { 9 + const timer = setInterval(() => { 10 + setCountdown((prev) => { 11 + if (prev <= 1) { 12 + clearInterval(timer); 13 + navigate("/"); 14 + return 0; 15 + } 16 + return prev - 1; 17 + }); 18 + }, 1000); 19 + 20 + return () => clearInterval(timer); 21 + }, [navigate]); 22 + 23 + return ( 24 + <div className="container"> 25 + <h1 className="title">404 - Page Not Found</h1> 26 + <div className="no-takes-message"> 27 + <p>Redirecting to home page in {countdown} seconds...</p> 28 + </div> 29 + </div> 30 + ); 31 + } 32 + 33 + export default NotFound;
+59
src/features/frontend/pages/Projects.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { prettyPrintTime } from "../../../libs/time"; 4 + import type { Project } from "../../api/routes/projects"; 5 + 6 + export function Projects() { 7 + const [projects, setProjects] = useState<Project[]>([]); 8 + 9 + useEffect(() => { 10 + async function getProjects() { 11 + try { 12 + const res = await fetch("/api/projects"); 13 + if (!res.ok) { 14 + throw new Error(`HTTP error! status: ${res.status}`); 15 + } 16 + const data = await res.json(); 17 + setProjects(data.projects); 18 + } catch (error) { 19 + console.error("Error fetching projects:", error); 20 + setProjects([]); 21 + } 22 + } 23 + getProjects(); 24 + }, []); 25 + 26 + return ( 27 + <div className="container"> 28 + <h1 className="title">Projects</h1> 29 + {projects.length === 0 ? ( 30 + <div className="no-takes-message">No projects found</div> 31 + ) : ( 32 + <div className="projects-grid"> 33 + {projects.map((project) => ( 34 + <Link 35 + to={`/user/${encodeURIComponent(project.userId)}`} 36 + key={project.projectName} 37 + className="project-card" 38 + > 39 + <img 40 + src={project.projectBannerUrl} 41 + alt={`${project.projectName} banner`} 42 + className="project-banner" 43 + /> 44 + <h2 className="project-title"> 45 + {project.projectName} 46 + </h2> 47 + <div className="project-meta"> 48 + <span> 49 + Total Time:{" "} 50 + {prettyPrintTime(project.totalTakesTime)} 51 + </span> 52 + </div> 53 + </Link> 54 + ))} 55 + </div> 56 + )} 57 + </div> 58 + ); 59 + }
+48
src/features/frontend/styles.css
··· 115 115 border-radius: 8px; 116 116 max-height: 40rem; 117 117 } 118 + 119 + .projects-grid { 120 + display: grid; 121 + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 122 + gap: 2rem; 123 + } 124 + 125 + .project-card { 126 + background: white; 127 + border-radius: 12px; 128 + padding: 1.5rem; 129 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 130 + text-decoration: none; 131 + color: inherit; 132 + transition: 133 + transform 0.2s, 134 + box-shadow 0.2s; 135 + } 136 + 137 + .project-card:hover { 138 + transform: translateY(-2px); 139 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 140 + } 141 + 142 + .project-title { 143 + font-size: 1.5rem; 144 + margin: 0 0 1rem 0; 145 + } 146 + 147 + .project-meta { 148 + color: #666; 149 + } 150 + 151 + .project-banner { 152 + width: 100%; 153 + height: 200px; 154 + object-fit: cover; 155 + border-radius: 8px; 156 + margin-bottom: 1rem; 157 + } 158 + 159 + .project-banner-container { 160 + width: 100%; 161 + margin-bottom: 2rem; 162 + border-radius: 12px; 163 + overflow: hidden; 164 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 165 + }
+115
src/features/takes/handlers/setup.ts
··· 1 + import type { UploadedFile } from "slack-edge"; 2 + import { slackApp, slackClient } from "../../../index"; 3 + import { db } from "../../../libs/db"; 4 + import { users as usersTable } from "../../../libs/schema"; 5 + 6 + export async function handleSetup(triggerID: string) { 7 + await slackClient.views.open({ 8 + trigger_id: triggerID, 9 + view: { 10 + type: "modal", 11 + title: { 12 + type: "plain_text", 13 + text: "Setup Project", 14 + }, 15 + submit: { 16 + type: "plain_text", 17 + text: "Submit", 18 + }, 19 + clear_on_close: true, 20 + callback_id: "takes_setup_submit", 21 + blocks: [ 22 + { 23 + type: "input", 24 + block_id: "project_name", 25 + label: { 26 + type: "plain_text", 27 + text: "Project Name", 28 + }, 29 + element: { 30 + type: "plain_text_input", 31 + action_id: "project_name_input", 32 + placeholder: { 33 + type: "plain_text", 34 + text: "Enter your project name", 35 + }, 36 + }, 37 + }, 38 + { 39 + type: "input", 40 + block_id: "project_description", 41 + label: { 42 + type: "plain_text", 43 + text: "Project Description", 44 + }, 45 + element: { 46 + type: "plain_text_input", 47 + action_id: "project_description_input", 48 + multiline: true, 49 + placeholder: { 50 + type: "plain_text", 51 + text: "Describe your project", 52 + }, 53 + }, 54 + }, 55 + { 56 + type: "input", 57 + block_id: "project_banner", 58 + label: { 59 + type: "plain_text", 60 + text: "Project Banner Image", 61 + }, 62 + element: { 63 + type: "file_input", 64 + action_id: "project_banner_input", 65 + }, 66 + }, 67 + ], 68 + }, 69 + }); 70 + } 71 + 72 + export async function setupSubmitListener() { 73 + slackApp.view( 74 + "takes_setup_submit", 75 + async () => Promise.resolve(), 76 + async ({ payload, body }) => { 77 + if (payload.type !== "view_submission") return; 78 + const values = payload.view.state.values; 79 + const userId = body.user.id; 80 + 81 + const file = values.project_banner?.project_banner_input 82 + ?.files?.[0] as UploadedFile; 83 + try { 84 + // If file is already public, use it directly 85 + const fileData = file.is_public 86 + ? file 87 + : ( 88 + await slackClient.files.sharedPublicURL({ 89 + file: file.id, 90 + token: process.env.SLACK_USER_TOKEN, 91 + }) 92 + ).file; 93 + 94 + const html = await ( 95 + await fetch(fileData?.permalink_public as string) 96 + ).text(); 97 + const projectBannerUrl = html.match( 98 + /https:\/\/files.slack.com\/files-pri\/[^"]+pub_secret=([^"&]*)/, 99 + )?.[0]; 100 + 101 + await db.insert(usersTable).values({ 102 + id: userId, 103 + projectName: values.project_name?.project_name_input 104 + ?.value as string, 105 + projectDescription: values.project_description 106 + ?.project_description_input?.value as string, 107 + projectBannerUrl, 108 + }); 109 + } catch (error) { 110 + console.error("Error processing file:", error); 111 + throw error; 112 + } 113 + }, 114 + ); 115 + }
+12
src/features/takes/setup/actions.ts
··· 3 3 import handleHelp from "../handlers/help"; 4 4 import { handleHistory } from "../handlers/history"; 5 5 import handleHome from "../handlers/home"; 6 + import { setupSubmitListener } from "../handlers/setup"; 6 7 import upload from "../services/upload"; 7 8 import type { MessageResponse } from "../types"; 8 9 import * as Sentry from "@sentry/bun"; ··· 70 71 Sentry.captureException(error, { 71 72 extra: { 72 73 context: "upload setup", 74 + }, 75 + }); 76 + } 77 + 78 + // setup the setup view handler 79 + try { 80 + setupSubmitListener(); 81 + } catch (error) { 82 + Sentry.captureException(error, { 83 + extra: { 84 + context: "submit modal setup", 73 85 }, 74 86 }); 75 87 }
+14
src/features/takes/setup/commands.ts
··· 5 5 import * as Sentry from "@sentry/bun"; 6 6 import { blog } from "../../../libs/Logger"; 7 7 import handleHome from "../handlers/home"; 8 + import { db } from "../../../libs/db"; 9 + import { users as usersTable } from "../../../libs/schema"; 10 + import { eq } from "drizzle-orm"; 11 + import { handleSetup } from "../handlers/setup"; 8 12 9 13 export default function setupCommands() { 10 14 // Main command handler ··· 19 23 const subcommand = args[0]?.toLowerCase() || ""; 20 24 21 25 let response: MessageResponse | undefined; 26 + 27 + const userFromDB = await db 28 + .select() 29 + .from(usersTable) 30 + .where(eq(usersTable.id, userId)); 31 + 32 + if (userFromDB.length === 0) { 33 + await handleSetup(context.triggerId as string); 34 + return; 35 + } 22 36 23 37 // Route to the appropriate handler function 24 38 switch (subcommand) {
+1
src/index.ts
··· 60 60 development: environment === "dev", 61 61 routes: { 62 62 "/": frontend, 63 + "/user/*": frontend, 63 64 "/health": new Response("OK"), 64 65 }, 65 66 async fetch(request: Request) {
+2 -1
src/libs/schema.ts
··· 17 17 18 18 export const users = pgTable("users", { 19 19 id: text("id").primaryKey(), 20 - totalTakesTime: integer("total_takes_time").default(0), 20 + totalTakesTime: integer("total_takes_time").default(0).notNull(), 21 21 hackatimeKeys: text("hackatime_keys").notNull().default("[]"), 22 22 projectName: text("project_name").notNull().default(""), 23 23 projectDescription: text("project_description").notNull().default(""), 24 + projectBannerUrl: text("project_banner_url").notNull().default(""), 24 25 usingHackatimeV2: boolean().notNull().default(true), 25 26 }); 26 27