-
+
Delete?
diff --git a/app/components/todo-item.tsx b/app/components/todo-item.tsx
index 7e8aec9..1815a9d 100644
--- a/app/components/todo-item.tsx
+++ b/app/components/todo-item.tsx
@@ -89,6 +89,9 @@ export function TodoItem({ task }: Props) {
+ {task.pinned && (
+
diff --git a/app/lib/send-discord.ts b/app/lib/send-discord.ts
index 73a5ea2..20facc4 100644
--- a/app/lib/send-discord.ts
+++ b/app/lib/send-discord.ts
@@ -83,7 +83,7 @@ async function createWebhookPayload(
if (project) {
embed.footer = {
- text: `📌 ${project?.name}`,
+ text: `📦 ${project?.name}`,
};
}
@@ -191,6 +191,34 @@ async function createWebhookPayload(
break;
}
+ case "task.pinned": {
+ const { task, user, pinned } = event as WebhookEvent<"task.pinned">;
+ if (!task) break;
+
+ embed.title = pinned ? "📌 Task Pinned" : "📌 Task Unpinned";
+ embed.description = `${task.title} \`#${task.id}\``;
+
+ if (projectUrl) {
+ embed.url = projectUrl;
+ }
+
+ embed.fields = [
+ {
+ name: "Status",
+ value: pinned ? "`Pinned`" : "`Unpinned`",
+ inline: true,
+ },
+ ];
+
+ if (user) {
+ embed.author = {
+ name: `@${user.username}`,
+ icon_url: `https://api.dicebear.com/9.x/dylan/png?seed=${encodeURIComponent(user.username)}`,
+ };
+ }
+ break;
+ }
+
case "task.assigned": {
const { task, user } = event as WebhookEvent<"task.assigned">;
if (!task) break;
@@ -294,6 +322,8 @@ function getColorForEvent(eventType: EventType): number {
return 0x818cf8;
case "task.assigned":
return 0xa78bfa;
+ case "task.pinned":
+ return 0xf59e0b;
case "task.deleted":
return 0xf87171;
case "comment.created":
diff --git a/app/lib/use-task-pin.ts b/app/lib/use-task-pin.ts
new file mode 100644
index 0000000..6487b64
--- /dev/null
+++ b/app/lib/use-task-pin.ts
@@ -0,0 +1,41 @@
+import type { Task } from "@prisma/client";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRevalidator } from "react-router";
+
+export function useTaskPin(task: Task) {
+ const queryClient = useQueryClient();
+ const { revalidate } = useRevalidator();
+
+ const pin = useMutation({
+ mutationKey: ["pin", task.id],
+ mutationFn: async () => {
+ return await pinTaskRequest({ taskId: task.id, pinned: !task.pinned });
+ },
+ onSuccess: async () => {
+ return await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["tasks"] }),
+ revalidate(),
+ ]);
+ },
+ });
+
+ return pin;
+}
+
+export async function pinTaskRequest({
+ taskId,
+ pinned,
+}: {
+ taskId: number;
+ pinned: boolean;
+}): Promise
{
+ const res = await fetch("/list", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ taskId, pinned }),
+ });
+
+ const data = await res.json();
+
+ return data.task;
+}
\ No newline at end of file
diff --git a/app/lib/webhook-types.ts b/app/lib/webhook-types.ts
index e38e5a1..24414c2 100644
--- a/app/lib/webhook-types.ts
+++ b/app/lib/webhook-types.ts
@@ -8,6 +8,7 @@ export type EventType =
| "task.deleted"
| "task.status_changed"
| "task.assigned"
+ | "task.pinned"
| "comment.created"
| "user.joined";
@@ -44,6 +45,13 @@ export type WebhookPayload = {
projectId: number;
};
+ "task.pinned": {
+ task: Task & { assignee?: SafeUser };
+ user?: SafeUser;
+ pinned: boolean;
+ projectId: number;
+ };
+
"comment.created": {
task: Task & { assignee?: SafeUser };
user?: SafeUser;
diff --git a/app/routes/list.tsx b/app/routes/list.tsx
index 6330bf6..34e9590 100644
--- a/app/routes/list.tsx
+++ b/app/routes/list.tsx
@@ -46,9 +46,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const tasks = await prisma.task.findMany({
where,
- orderBy: {
- createdAt: "desc",
- },
+ orderBy: [
+ {
+ pinned: "desc",
+ },
+ {
+ createdAt: "desc",
+ },
+ ],
include: {
_count: { select: { Comment: true } },
assignee: { select: { username: true, id: true } },
@@ -110,6 +115,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
assigneeId: true,
status: true,
title: true,
+ pinned: true,
},
});
@@ -117,6 +123,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const previousAssigneeId = previous.assigneeId;
const previousStatus = previous.status;
+ const previousPinned = previous.pinned;
const task = await prisma.task.update({
where: { id },
@@ -142,6 +149,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
}
+ if (previousPinned !== updates.pinned) {
+ sendWebhook("task.pinned", {
+ task,
+ user,
+ pinned: updates.pinned,
+ projectId: task.projectId,
+ });
+ }
+
if (updates.assigneeId && previousAssigneeId !== updates.assigneeId) {
if (updates.assigneeId !== user.id) {
// don't notify self
diff --git a/prisma/migrations/20250608090308_pin_tasks/migration.sql b/prisma/migrations/20250608090308_pin_tasks/migration.sql
new file mode 100644
index 0000000..fbd8765
--- /dev/null
+++ b/prisma/migrations/20250608090308_pin_tasks/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Task" ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b7ae4b2..891c74d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -40,6 +40,7 @@ model Task {
id Int @id @default(autoincrement())
title String
status Status @default(pending)
+ pinned Boolean @default(false)
author User @relation("author", fields: [authorId], references: [id])
assignee User @relation("assignee", fields: [assigneeId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)