a fun bot for the hc slack

feat: add user creation and frontend

dunkirk.sh 699b0688 d6d6fc83

verified
Changed files
+434 -25
src
+11
bun.lock
···
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-masonry-css": "^1.0.16",
+
"react-router-dom": "^7.5.1",
"slack-edge": "^1.3.7",
"yaml": "^2.7.1",
},
···
"colors": ["colors@1.4.0", "", {}, "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="],
+
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
···
"react-masonry-css": ["react-masonry-css@1.0.16", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ=="],
+
"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=="],
+
+
"react-router-dom": ["react-router-dom@7.5.1", "", { "dependencies": { "react-router": "7.5.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5DPSPc7ENrt2tlKPq0FtpG80ZbqA9aIKEyqX6hSNJDlol/tr6iqCK4crqdsusmOSSotq6zDsn0y3urX9TuTNmA=="],
+
"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=="],
"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=="],
···
"semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
+
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
+
"shell-quote": ["shell-quote@1.8.2", "", {}, "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA=="],
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
···
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+
+
"turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="],
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
+1
package.json
···
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-masonry-css": "^1.0.16",
+
"react-router-dom": "^7.5.1",
"slack-edge": "^1.3.7",
"yaml": "^2.7.1"
}
+3
src/features/api/index.ts
···
import { recentTakes } from "./routes/recentTakes";
import video from "./routes/video";
import { handleApiError } from "../../libs/apiError";
+
import { projects } from "./routes/projects";
export { default as video } from "./routes/video";
···
return await video(url);
case "recentTakes":
return await recentTakes(url);
+
case "projects":
+
return await projects(url);
default:
return new Response(
JSON.stringify({ error: "Route not found" }),
+54
src/features/api/routes/projects.ts
···
+
import { db } from "../../../libs/db";
+
import { users as usersTable } from "../../../libs/schema";
+
import { handleApiError } from "../../../libs/apiError";
+
import { eq } from "drizzle-orm";
+
+
export type Project = {
+
projectName: string;
+
projectDescription: string;
+
projectBannerUrl: string;
+
totalTakesTime: number;
+
userId: string;
+
};
+
+
export async function projects(url: URL): Promise<Response> {
+
const user = url.searchParams.get("user");
+
try {
+
const projects = await db
+
.select({
+
projectName: usersTable.projectName,
+
projectDescription: usersTable.projectDescription,
+
projectBannerUrl: usersTable.projectBannerUrl,
+
totalTakesTime: usersTable.totalTakesTime,
+
userId: usersTable.id,
+
})
+
.from(usersTable)
+
.where(eq(usersTable.id, user ? user : usersTable.id));
+
+
if (projects.length === 0) {
+
return new Response(
+
JSON.stringify({
+
projects: [],
+
}),
+
{
+
headers: {
+
"Content-Type": "application/json",
+
},
+
},
+
);
+
}
+
+
return new Response(
+
JSON.stringify({
+
projects: user ? projects[0] : projects,
+
}),
+
{
+
headers: {
+
"Content-Type": "application/json",
+
},
+
},
+
);
+
} catch (error) {
+
return handleApiError(error, "projects");
+
}
+
}
+16
src/features/frontend/App.tsx
···
+
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
+
import { Projects } from "./pages/Projects";
+
import { ProjectTakes } from "./pages/ProjectTakes";
+
import { NotFound } from "./pages/404";
+
+
export function App() {
+
return (
+
<Router>
+
<Routes>
+
<Route path="/" element={<Projects />} />
+
<Route path="/user/:user" element={<ProjectTakes />} />
+
<Route path="*" element={<NotFound />} />
+
</Routes>
+
</Router>
+
);
+
}
+64 -23
src/features/frontend/app.tsx src/features/frontend/pages/ProjectTakes.tsx
···
import { useEffect, useState } from "react";
-
import { prettyPrintTime } from "../../libs/time";
-
import { fetchUserData } from "../../libs/cachet";
-
import type { RecentTake } from "../api/routes/recentTakes";
+
import { useParams } from "react-router-dom";
+
import { prettyPrintTime } from "../../../libs/time";
+
import { fetchUserData } from "../../../libs/cachet";
+
import type { RecentTake } from "../../api/routes/recentTakes";
+
import type { Project } from "../../api/routes/projects";
import Masonry from "react-masonry-css";
-
export function App() {
+
export function ProjectTakes() {
+
const { user } = useParams();
const [takes, setTakes] = useState<RecentTake[]>([]);
-
const [userData, setUserData] = useState<{
[key: string]: { displayName: string; imageUrl: string };
}>({});
+
const [project, setProject] = useState<Project>();
+
+
useEffect(() => {
+
async function getTakes() {
+
try {
+
const res = await fetch(
+
`/api/recentTakes?user=${encodeURIComponent(user as string)}`,
+
);
+
if (!res.ok) {
+
throw new Error(`HTTP error! status: ${res.status}`);
+
}
+
const data = await res.json();
+
setTakes(data.takes);
+
} catch (error) {
+
console.error("Error fetching takes:", error);
+
setTakes([]);
+
}
+
}
+
+
async function getProject() {
+
try {
+
const res = await fetch(
+
`/api/projects?user=${encodeURIComponent(user as string)}`,
+
);
+
if (!res.ok) {
+
throw new Error(`HTTP error! status: ${res.status}`);
+
}
+
const data = await res.json();
+
setProject(data.projects);
+
} catch (error) {
+
console.error("Error fetching project:", error);
+
}
+
}
+
+
getTakes();
+
getProject();
+
}, [user]);
+
useEffect(() => {
async function loadUserData() {
const userIds = takes.map((take) => take.userId);
···
loadUserData();
}, [takes]);
-
useEffect(() => {
-
async function getTakes() {
-
try {
-
const res = await fetch("/api/recentTakes");
-
if (!res.ok) {
-
throw new Error(`HTTP error! status: ${res.status}`);
-
}
-
const data = await res.json();
-
setTakes(data.takes);
-
} catch (error) {
-
console.error("Error fetching takes:", error);
-
setTakes([]);
-
}
-
}
-
getTakes();
-
}, []);
-
const breakpointColumns = {
default: 4,
1100: 3,
···
return (
<div className="container">
-
<h1 className="title">Recent Takes</h1>
+
<section className="project-header">
+
{project?.projectBannerUrl && (
+
<img
+
src={project.projectBannerUrl}
+
alt="Project banner"
+
className="project-banner"
+
style={{
+
width: "100%",
+
height: "200px",
+
objectFit: "cover",
+
borderRadius: "12px",
+
marginBottom: "2rem",
+
}}
+
/>
+
)}
+
<h1 className="title">
+
{project?.projectName || "Recent Takes"}
+
</h1>
+
</section>
{takes.length === 0 ? (
<div className="no-takes-message">No takes found</div>
) : (
+1 -1
src/features/frontend/index.tsx
···
import "./styles.css";
import { createRoot } from "react-dom/client";
-
import { App } from "./app.tsx";
+
import { App } from "./App.tsx";
document.addEventListener("DOMContentLoaded", () => {
const element = document.getElementById("root");
+33
src/features/frontend/pages/404.tsx
···
+
import { useNavigate } from "react-router-dom";
+
import { useEffect, useState } from "react";
+
+
export function NotFound() {
+
const navigate = useNavigate();
+
const [countdown, setCountdown] = useState(5);
+
+
useEffect(() => {
+
const timer = setInterval(() => {
+
setCountdown((prev) => {
+
if (prev <= 1) {
+
clearInterval(timer);
+
navigate("/");
+
return 0;
+
}
+
return prev - 1;
+
});
+
}, 1000);
+
+
return () => clearInterval(timer);
+
}, [navigate]);
+
+
return (
+
<div className="container">
+
<h1 className="title">404 - Page Not Found</h1>
+
<div className="no-takes-message">
+
<p>Redirecting to home page in {countdown} seconds...</p>
+
</div>
+
</div>
+
);
+
}
+
+
export default NotFound;
+59
src/features/frontend/pages/Projects.tsx
···
+
import { useEffect, useState } from "react";
+
import { Link } from "react-router-dom";
+
import { prettyPrintTime } from "../../../libs/time";
+
import type { Project } from "../../api/routes/projects";
+
+
export function Projects() {
+
const [projects, setProjects] = useState<Project[]>([]);
+
+
useEffect(() => {
+
async function getProjects() {
+
try {
+
const res = await fetch("/api/projects");
+
if (!res.ok) {
+
throw new Error(`HTTP error! status: ${res.status}`);
+
}
+
const data = await res.json();
+
setProjects(data.projects);
+
} catch (error) {
+
console.error("Error fetching projects:", error);
+
setProjects([]);
+
}
+
}
+
getProjects();
+
}, []);
+
+
return (
+
<div className="container">
+
<h1 className="title">Projects</h1>
+
{projects.length === 0 ? (
+
<div className="no-takes-message">No projects found</div>
+
) : (
+
<div className="projects-grid">
+
{projects.map((project) => (
+
<Link
+
to={`/user/${encodeURIComponent(project.userId)}`}
+
key={project.projectName}
+
className="project-card"
+
>
+
<img
+
src={project.projectBannerUrl}
+
alt={`${project.projectName} banner`}
+
className="project-banner"
+
/>
+
<h2 className="project-title">
+
{project.projectName}
+
</h2>
+
<div className="project-meta">
+
<span>
+
Total Time:{" "}
+
{prettyPrintTime(project.totalTakesTime)}
+
</span>
+
</div>
+
</Link>
+
))}
+
</div>
+
)}
+
</div>
+
);
+
}
+48
src/features/frontend/styles.css
···
border-radius: 8px;
max-height: 40rem;
}
+
+
.projects-grid {
+
display: grid;
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+
gap: 2rem;
+
}
+
+
.project-card {
+
background: white;
+
border-radius: 12px;
+
padding: 1.5rem;
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+
text-decoration: none;
+
color: inherit;
+
transition:
+
transform 0.2s,
+
box-shadow 0.2s;
+
}
+
+
.project-card:hover {
+
transform: translateY(-2px);
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+
}
+
+
.project-title {
+
font-size: 1.5rem;
+
margin: 0 0 1rem 0;
+
}
+
+
.project-meta {
+
color: #666;
+
}
+
+
.project-banner {
+
width: 100%;
+
height: 200px;
+
object-fit: cover;
+
border-radius: 8px;
+
margin-bottom: 1rem;
+
}
+
+
.project-banner-container {
+
width: 100%;
+
margin-bottom: 2rem;
+
border-radius: 12px;
+
overflow: hidden;
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+
}
+115
src/features/takes/handlers/setup.ts
···
+
import type { UploadedFile } from "slack-edge";
+
import { slackApp, slackClient } from "../../../index";
+
import { db } from "../../../libs/db";
+
import { users as usersTable } from "../../../libs/schema";
+
+
export async function handleSetup(triggerID: string) {
+
await slackClient.views.open({
+
trigger_id: triggerID,
+
view: {
+
type: "modal",
+
title: {
+
type: "plain_text",
+
text: "Setup Project",
+
},
+
submit: {
+
type: "plain_text",
+
text: "Submit",
+
},
+
clear_on_close: true,
+
callback_id: "takes_setup_submit",
+
blocks: [
+
{
+
type: "input",
+
block_id: "project_name",
+
label: {
+
type: "plain_text",
+
text: "Project Name",
+
},
+
element: {
+
type: "plain_text_input",
+
action_id: "project_name_input",
+
placeholder: {
+
type: "plain_text",
+
text: "Enter your project name",
+
},
+
},
+
},
+
{
+
type: "input",
+
block_id: "project_description",
+
label: {
+
type: "plain_text",
+
text: "Project Description",
+
},
+
element: {
+
type: "plain_text_input",
+
action_id: "project_description_input",
+
multiline: true,
+
placeholder: {
+
type: "plain_text",
+
text: "Describe your project",
+
},
+
},
+
},
+
{
+
type: "input",
+
block_id: "project_banner",
+
label: {
+
type: "plain_text",
+
text: "Project Banner Image",
+
},
+
element: {
+
type: "file_input",
+
action_id: "project_banner_input",
+
},
+
},
+
],
+
},
+
});
+
}
+
+
export async function setupSubmitListener() {
+
slackApp.view(
+
"takes_setup_submit",
+
async () => Promise.resolve(),
+
async ({ payload, body }) => {
+
if (payload.type !== "view_submission") return;
+
const values = payload.view.state.values;
+
const userId = body.user.id;
+
+
const file = values.project_banner?.project_banner_input
+
?.files?.[0] as UploadedFile;
+
try {
+
// If file is already public, use it directly
+
const fileData = file.is_public
+
? file
+
: (
+
await slackClient.files.sharedPublicURL({
+
file: file.id,
+
token: process.env.SLACK_USER_TOKEN,
+
})
+
).file;
+
+
const html = await (
+
await fetch(fileData?.permalink_public as string)
+
).text();
+
const projectBannerUrl = html.match(
+
/https:\/\/files.slack.com\/files-pri\/[^"]+pub_secret=([^"&]*)/,
+
)?.[0];
+
+
await db.insert(usersTable).values({
+
id: userId,
+
projectName: values.project_name?.project_name_input
+
?.value as string,
+
projectDescription: values.project_description
+
?.project_description_input?.value as string,
+
projectBannerUrl,
+
});
+
} catch (error) {
+
console.error("Error processing file:", error);
+
throw error;
+
}
+
},
+
);
+
}
+12
src/features/takes/setup/actions.ts
···
import handleHelp from "../handlers/help";
import { handleHistory } from "../handlers/history";
import handleHome from "../handlers/home";
+
import { setupSubmitListener } from "../handlers/setup";
import upload from "../services/upload";
import type { MessageResponse } from "../types";
import * as Sentry from "@sentry/bun";
···
Sentry.captureException(error, {
extra: {
context: "upload setup",
+
},
+
});
+
}
+
+
// setup the setup view handler
+
try {
+
setupSubmitListener();
+
} catch (error) {
+
Sentry.captureException(error, {
+
extra: {
+
context: "submit modal setup",
},
});
}
+14
src/features/takes/setup/commands.ts
···
import * as Sentry from "@sentry/bun";
import { blog } from "../../../libs/Logger";
import handleHome from "../handlers/home";
+
import { db } from "../../../libs/db";
+
import { users as usersTable } from "../../../libs/schema";
+
import { eq } from "drizzle-orm";
+
import { handleSetup } from "../handlers/setup";
export default function setupCommands() {
// Main command handler
···
const subcommand = args[0]?.toLowerCase() || "";
let response: MessageResponse | undefined;
+
+
const userFromDB = await db
+
.select()
+
.from(usersTable)
+
.where(eq(usersTable.id, userId));
+
+
if (userFromDB.length === 0) {
+
await handleSetup(context.triggerId as string);
+
return;
+
}
// Route to the appropriate handler function
switch (subcommand) {
+1
src/index.ts
···
development: environment === "dev",
routes: {
"/": frontend,
+
"/user/*": frontend,
"/health": new Response("OK"),
},
async fetch(request: Request) {
+2 -1
src/libs/schema.ts
···
export const users = pgTable("users", {
id: text("id").primaryKey(),
-
totalTakesTime: integer("total_takes_time").default(0),
+
totalTakesTime: integer("total_takes_time").default(0).notNull(),
hackatimeKeys: text("hackatime_keys").notNull().default("[]"),
projectName: text("project_name").notNull().default(""),
projectDescription: text("project_description").notNull().default(""),
+
projectBannerUrl: text("project_banner_url").notNull().default(""),
usingHackatimeV2: boolean().notNull().default(true),
});