diff --git a/.gitignore b/.gitignore index c758da8..de72281 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ temp .claude .gemini .idea +.agents diff --git a/apps/server/src/modules/columns/create-column/schemas.ts b/apps/server/src/modules/columns/create-column/schemas.ts index 47d5ed4..55a7dbd 100644 --- a/apps/server/src/modules/columns/create-column/schemas.ts +++ b/apps/server/src/modules/columns/create-column/schemas.ts @@ -3,6 +3,7 @@ import { zDate } from "@/shared/schemas/zod-date"; export const createColumnBodySchema = z.object({ name: z.string().min(1), + description: z.string().optional(), color: z.string().optional(), isCompleted: z.boolean().optional().default(false), }); @@ -11,6 +12,7 @@ export type CreateColumnInput = z.infer; export const createColumnResponseSchema = z.object({ name: z.string(), + description: z.string().nullable(), id: z.string(), createdAt: zDate, updatedAt: zDate, diff --git a/apps/server/src/modules/columns/create-column/use-case.ts b/apps/server/src/modules/columns/create-column/use-case.ts index 8e4c6d1..df6bdb6 100644 --- a/apps/server/src/modules/columns/create-column/use-case.ts +++ b/apps/server/src/modules/columns/create-column/use-case.ts @@ -13,6 +13,7 @@ export async function createColumnUseCase( return prisma.column.create({ data: { name: input.name, + description: input.description, color: input.color, order: lastColumn ? lastColumn.order + 1 : 0, isCompleted: input.isCompleted ?? false, diff --git a/apps/server/src/modules/columns/delete-column/schemas.ts b/apps/server/src/modules/columns/delete-column/schemas.ts index c9f2b96..4c35067 100644 --- a/apps/server/src/modules/columns/delete-column/schemas.ts +++ b/apps/server/src/modules/columns/delete-column/schemas.ts @@ -8,6 +8,7 @@ export const deleteColumnParamsSchema = z.object({ export const deleteColumnResponseSchema = z.object({ id: z.string(), name: z.string(), + description: z.string().nullable(), createdAt: zDate, updatedAt: zDate, organizationId: z.string(), diff --git a/apps/server/src/modules/columns/get-columns/schemas.ts b/apps/server/src/modules/columns/get-columns/schemas.ts index 0c8b794..3f49d2b 100644 --- a/apps/server/src/modules/columns/get-columns/schemas.ts +++ b/apps/server/src/modules/columns/get-columns/schemas.ts @@ -33,6 +33,7 @@ export const getColumnsSucessResponseSchema = z .array(), id: z.string(), name: z.string(), + description: z.string().nullable(), color: z.string().nullable(), order: z.number(), isCompleted: z.boolean(), diff --git a/apps/server/src/modules/columns/reorder-columns/schemas.ts b/apps/server/src/modules/columns/reorder-columns/schemas.ts index 67c4cca..adc3b18 100644 --- a/apps/server/src/modules/columns/reorder-columns/schemas.ts +++ b/apps/server/src/modules/columns/reorder-columns/schemas.ts @@ -16,6 +16,7 @@ export const reorderColumnsResponseSchema = z .object({ id: z.string(), name: z.string(), + description: z.string().nullable(), createdAt: zDate, updatedAt: zDate, organizationId: z.string(), diff --git a/apps/server/src/modules/columns/update-column/schemas.ts b/apps/server/src/modules/columns/update-column/schemas.ts index 2d37391..460e357 100644 --- a/apps/server/src/modules/columns/update-column/schemas.ts +++ b/apps/server/src/modules/columns/update-column/schemas.ts @@ -7,6 +7,7 @@ export const updateColumnParamsSchema = z.object({ export const updateColumnBodySchema = z.object({ name: z.string().min(1).optional(), + description: z.string().nullable().optional(), color: z.string().optional(), order: z.number().int().optional(), isCompleted: z.boolean().optional(), @@ -17,6 +18,7 @@ export type UpdateColumnInput = z.infer; export const updateColumnResponseSchema = z.object({ id: z.string(), name: z.string(), + description: z.string().nullable(), createdAt: zDate, updatedAt: zDate, organizationId: z.string(), diff --git a/apps/server/src/modules/columns/update-column/use-case.ts b/apps/server/src/modules/columns/update-column/use-case.ts index 8735cf8..5f70e0e 100644 --- a/apps/server/src/modules/columns/update-column/use-case.ts +++ b/apps/server/src/modules/columns/update-column/use-case.ts @@ -9,6 +9,7 @@ export async function updateColumnUseCase( where: { id }, data: { name: input.name, + description: input.description, color: input.color, order: input.order, isCompleted: input.isCompleted, diff --git a/apps/server/src/shared/http/index.ts b/apps/server/src/shared/http/index.ts index daa9bd9..7e1387d 100644 --- a/apps/server/src/shared/http/index.ts +++ b/apps/server/src/shared/http/index.ts @@ -7,6 +7,7 @@ import { columnsRouter } from "../../modules/columns/router"; import { tasksRouter } from "../../modules/tasks/router"; import { openapiConfig } from "../config/openapi"; import { authPlugin } from "./plugins/auth.plugin"; +import { wsPlugin } from "./plugins/ws.plugin"; const app = new Elysia() .use( @@ -19,6 +20,7 @@ const app = new Elysia() ) .use(openapi(openapiConfig)) .use(authPlugin) + .use(wsPlugin) .use([columnsRouter, tasksRouter]) .listen(env.PORT, ({ hostname, port }) => console.log(`Server is running on http://${hostname}:${port}`), diff --git a/apps/server/src/shared/http/plugins/ws.plugin.ts b/apps/server/src/shared/http/plugins/ws.plugin.ts new file mode 100644 index 0000000..ee64160 --- /dev/null +++ b/apps/server/src/shared/http/plugins/ws.plugin.ts @@ -0,0 +1,163 @@ +import { auth } from "@blaboard/auth"; +import { Elysia, t } from "elysia"; + +type WsClient = { + readyState: number; + send: (data: string) => void; +}; + +const orgClients: Map> = new Map(); +const MAX_MESSAGE_SIZE = 1024 * 100; + +function broadcastToOrg(orgId: string, message: unknown, excludeWsId?: string) { + const clients = orgClients.get(orgId); + if (!clients) return; + + const messageStr = JSON.stringify(message); + + if (messageStr.length > MAX_MESSAGE_SIZE) { + return; + } + + for (const [wsId, ws] of clients.entries()) { + if (wsId !== excludeWsId && ws.readyState === 1) { + ws.send(messageStr); + } + } +} + +function addClientToOrg(orgId: string, wsId: string, ws: WsClient) { + if (!orgClients.has(orgId)) { + orgClients.set(orgId, new Map()); + } + orgClients.get(orgId)?.set(wsId, ws); +} + +function removeClientFromOrg(orgId: string, wsId: string) { + const clients = orgClients.get(orgId); + if (!clients) return; + + clients.delete(wsId); + + if (clients.size === 0) { + orgClients.delete(orgId); + } +} + +export const wsPlugin = new Elysia({ name: "ws" }) + .ws("/ws", { + body: t.Union([ + t.Object({ + type: t.Literal("task:created"), + data: t.Object({ + taskId: t.String(), + columnId: t.String(), + title: t.String(), + }), + }), + t.Object({ + type: t.Literal("task:updated"), + data: t.Object({ + taskId: t.String(), + }), + }), + t.Object({ + type: t.Literal("task:deleted"), + data: t.Object({ + taskId: t.String(), + }), + }), + t.Object({ + type: t.Literal("task:moved"), + data: t.Object({ + taskId: t.String(), + columnId: t.String(), + order: t.Number(), + }), + }), + t.Object({ + type: t.Literal("column:created"), + data: t.Object({ + columnId: t.String(), + name: t.String(), + }), + }), + t.Object({ + type: t.Literal("column:updated"), + data: t.Object({ + columnId: t.String(), + }), + }), + t.Object({ + type: t.Literal("column:deleted"), + data: t.Object({ + columnId: t.String(), + }), + }), + t.Object({ + type: t.Literal("columns:reordered"), + data: t.Object({ + columns: t.Array( + t.Object({ + id: t.String(), + order: t.Number(), + }), + ), + }), + }), + t.Object({ + type: t.Literal("ping"), + }), + t.Object({ + type: t.Literal("pong"), + }), + ]), + query: t.Object({ + orgId: t.String(), + }), + async open(ws) { + const requestedOrgId = ws.data.query.orgId; + + const headers = new Headers(); + for (const [key, value] of Object.entries(ws.data.headers)) { + if (value) headers.set(key, value); + } + const session = await auth.api.getSession({ headers }); + + if (!session) { + ws.close(1008, "Unauthorized"); + return; + } + + const activeOrganizationId = session.session.activeOrganizationId; + if (typeof activeOrganizationId !== "string") { + ws.close(1008, "Organization required"); + return; + } + + if (requestedOrgId !== activeOrganizationId) { + ws.close(1008, "Forbidden"); + return; + } + + addClientToOrg(activeOrganizationId, ws.id, ws.raw); + }, + message(ws, message) { + const orgId = ws.data.query.orgId; + + if (message.type === "ping") { + if (ws.raw.readyState === 1) { + ws.raw.send(JSON.stringify({ type: "pong" })); + } + return; + } + + if (message.type === "pong") return; + + broadcastToOrg(orgId, message, ws.id); + }, + close(ws) { + const orgId = ws.data.query.orgId; + removeClientFromOrg(orgId, ws.id); + }, + }); \ No newline at end of file diff --git a/apps/web/.env.example b/apps/web/.env.example index 442eb78..169bfd5 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1 +1,2 @@ NEXT_PUBLIC_SERVER_URL="http://localhost:3000" +NEXT_PUBLIC_FRONTEND_URL="http://localhost:3001" diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index dcb3e64..ed2ec33 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,8 +2,8 @@ import "@blaboard/env/web"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { - typedRoutes: true, - reactCompiler: true, + typedRoutes: true, + reactCompiler: true, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 4fba826..3ffdbeb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,9 +9,9 @@ }, "dependencies": { "@base-ui/react": "^1.0.0", - "@blaboard/server": "workspace:*", "@blaboard/auth": "workspace:*", "@blaboard/env": "workspace:*", + "@blaboard/server": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -24,8 +24,10 @@ "better-auth": "catalog:", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "dotenv": "catalog:", "elysia": "1.4.22", + "framer-motion": "^12.31.0", "lucide-react": "^0.563.0", "next": "^16.1.1", "next-themes": "^0.4.6", diff --git a/apps/web/public/android-chrome-192x192.png b/apps/web/public/android-chrome-192x192.png new file mode 100644 index 0000000..a7b25ca Binary files /dev/null and b/apps/web/public/android-chrome-192x192.png differ diff --git a/apps/web/public/android-chrome-512x512.png b/apps/web/public/android-chrome-512x512.png new file mode 100644 index 0000000..4be733c Binary files /dev/null and b/apps/web/public/android-chrome-512x512.png differ diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000..2d23afe Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png new file mode 100644 index 0000000..0de19b4 Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png new file mode 100644 index 0000000..349d943 Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000..f1be604 Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/apps/web/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/apps/web/src/app/(authenticated)/layout.tsx b/apps/web/src/app/(authenticated)/layout.tsx new file mode 100644 index 0000000..6e4c3b0 --- /dev/null +++ b/apps/web/src/app/(authenticated)/layout.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Sidebar, SidebarProvider, SidebarTrigger } from "~/components/layout"; +import { OrgGuard } from "~/components/org"; +import { + CommandPalette, + CommandPaletteProvider, +} from "~/components/command-palette"; + +export default function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
+ +
+ {/* Mobile Header */} +
+ + Blaboard +
+
+ {children} +
+
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(authenticated)/page.tsx b/apps/web/src/app/(authenticated)/page.tsx new file mode 100644 index 0000000..83ed28e --- /dev/null +++ b/apps/web/src/app/(authenticated)/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { TaskBoard } from "~/components/board"; +import { authClient } from "~/lib/auth-client"; + +export default function Home() { + const { data: session } = authClient.useSession(); + + if (!session?.user || !session?.session?.activeOrganizationId) { + return null; + } + + return ( + + ); +} diff --git a/apps/web/src/app/(authenticated)/task/[taskId]/page.tsx b/apps/web/src/app/(authenticated)/task/[taskId]/page.tsx new file mode 100644 index 0000000..1e24c32 --- /dev/null +++ b/apps/web/src/app/(authenticated)/task/[taskId]/page.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { + ArrowLeftIcon, + CalendarIcon, + DotsThreeIcon, + PencilSimpleIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { use, useState } from "react"; +import { toast } from "sonner"; +import { Avatar, AvatarFallback } from "~/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { + useColumns, + useDeleteTask, + useTask, + useUpdateTask, +} from "~/hooks/board"; +import type { UpdateTaskInput } from "~/lib/types"; +import Loader from "~/components/loader"; + +const priorityColors = { + HIGH: "#ef4444", + MEDIUM: "#f59e0b", + LOW: "#22c55e", + NONE: "transparent", +}; + +const priorityLabels = { + HIGH: "High", + MEDIUM: "Medium", + LOW: "Low", + NONE: "None", +}; + +function getInitials(name: string): string { + return name + .split(" ") + .map((part) => part[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + +function stringToColor(str: string): string { + const colors = ["#6366f1", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6"]; + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; +} + +function formatDate(date: Date | string | null): string { + if (!date) return "Not set"; + const d = new Date(date); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +interface PageProps { + params: Promise<{ taskId: string }>; +} + +export default function TaskDetailsPage({ params }: PageProps) { + const { taskId } = use(params); + const router = useRouter(); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const { data: task, isLoading, error, refetch } = useTask(taskId); + const { data: columns = [] } = useColumns(task?.organizationId ?? ""); + const deleteTaskMutation = useDeleteTask(task?.organizationId ?? ""); + const updateTaskMutation = useUpdateTask(task?.organizationId ?? ""); + + const handleDelete = async () => { + if (!task) return; + + try { + await deleteTaskMutation.mutateAsync(task.id); + toast.success("Task deleted successfully"); + router.push("/"); + } catch { + toast.error("Failed to delete task"); + } + }; + + const handleUpdate = async (input: UpdateTaskInput) => { + if (!task) return; + + try { + await updateTaskMutation.mutateAsync({ id: task.id, input }); + await refetch(); + toast.success("Task updated successfully"); + } catch { + toast.error("Failed to update task"); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !task) { + return ( +
+
Task not found
+ + Back to board + +
+ ); + } + + return ( + <> +
+
+ + + +
+ Project Overview + / + Task Details +
+
+ +
+ + + + + + + + + Delete task + + + +
+
+ +
+
+
+
+ {task.priority !== "NONE" && ( +
+ )} +

+ {task.title} +

+
+
+
+
+ + {task.column.name} + +
+ + Created {formatDate(task.createdAt)} + +
+
+ + {task.description && ( +
+ + Description + +

+ {task.description} +

+
+ )} + + {task.labels && task.labels.length > 0 && ( +
+ + Labels + +
+ {task.labels.map((label) => ( +
+ + {label.text} + +
+ ))} +
+
+ )} +
+ +
+ + Details + + +
+
+ + Assignee + + {task.assignee ? ( +
+ + + {getInitials(task.assignee.name)} + + + + {task.assignee.name} + +
+ ) : ( + + Unassigned + + )} +
+ +
+ + Due Date + +
+ + + {formatDate(task.dueDate)} + +
+
+ +
+ + Priority + +
+ {task.priority !== "NONE" && ( +
+ )} + + {priorityLabels[task.priority]} + +
+
+ +
+ Status +
+
+ + {task.column.name} + +
+
+
+ +
+ +
+ + Created by + +
+ + + {getInitials(task.createdBy.name)} + + +
+ + {task.createdBy.name} + + + {formatDate(task.createdAt)} + +
+
+
+
+
+ + ); +} diff --git a/apps/web/src/app/(board)/page.tsx b/apps/web/src/app/(board)/page.tsx deleted file mode 100644 index ca5d283..0000000 --- a/apps/web/src/app/(board)/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { TaskBoard } from "~/components/board"; -import { OrgGuard } from "~/components/org"; -import { authClient } from "~/lib/auth-client"; - -export default function Home() { - const { data: session } = authClient.useSession(); - - return ( - - {session?.user && session?.session?.activeOrganizationId && ( - - )} - - ); -} diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/apps/web/src/app/favicon.ico and /dev/null differ diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 53a2221..6679d28 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,37 +1,77 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; - -import "../index.css"; import Providers from "~/components/providers"; +import { META_THEME_COLORS, siteConfig } from "~/lib/config"; +import { fontVariables } from "~/lib/fonts"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import "../index.css"; export const metadata: Metadata = { - title: "blaboard", - description: "blaboard", + title: { + default: siteConfig.name, + template: `%s - ${siteConfig.name}`, + }, + metadataBase: new URL(process.env.NEXT_PUBLIC_FRONTEND_URL!), + description: siteConfig.description, + keywords: siteConfig.keywords, + authors: siteConfig.authors, + creator: "Berolab", + openGraph: { + type: "website", + locale: "en_US", + url: process.env.NEXT_PUBLIC_FRONTEND_URL!, + title: siteConfig.name, + description: siteConfig.description, + siteName: siteConfig.name, + images: [ + { + url: `${process.env.NEXT_PUBLIC_FRONTEND_URL}/opengraph-image.png`, + width: 1200, + height: 630, + alt: siteConfig.name, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: siteConfig.name, + description: siteConfig.description, + images: [`${process.env.NEXT_PUBLIC_FRONTEND_URL}/opengraph-image.png`], + creator: "@berolab", + }, + icons: { + icon: "/favicon.ico", + shortcut: "/favicon-16x16.png", + apple: "/apple-touch-icon.png", + }, + manifest: `${siteConfig.url}/site.webmanifest`, }; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - - {children} - - - ); + return ( + + +