Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ If a `WEBHOOK_URL` is provided, the endpoint is called with the following events
| `task.deleted` | Triggered when a task is deleted |
| `task.status_changed` | Triggered when a task's status changes |
| `task.assigned` | Triggered when a task is assigned to a user |
| `task.pinned` | Triggered when a task is pinned or unpinned |
| `comment.created` | Triggered when a comment is added to a task |
| `user.joined` | Triggered when a new user joins the system |

Expand Down
24 changes: 22 additions & 2 deletions app/components/status-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import clsx from "clsx";
import React from "react";
import { statuses } from "~/lib/statuses";
import { useTaskDelete } from "~/lib/use-task-delete";
import { useTaskPin } from "~/lib/use-task-pin";
import { usePopoverContext } from "./popover";

interface StatusMenuProps {
Expand All @@ -13,6 +14,7 @@ interface StatusMenuProps {
export function StatusMenu({ task, onStatusUpdate }: StatusMenuProps) {
const [confirmingDelete, setConfirmingDelete] = React.useState(false);
const remove = useTaskDelete(task);
const pin = useTaskPin(task);

const popover = usePopoverContext();

Expand All @@ -36,7 +38,8 @@ export function StatusMenu({ task, onStatusUpdate }: StatusMenuProps) {
className={clsx(
"flex items-center pl-3 rounded-lg hover:bg-neutral-200/80 dark:hover:bg-neutral-800/20",
{
"bg-neutral-200/80 dark:bg-neutral-800/20": s.id === task.status,
"bg-neutral-200/80 dark:bg-neutral-800/20":
s.id === task.status,
},
)}
>
Expand All @@ -59,11 +62,28 @@ export function StatusMenu({ task, onStatusUpdate }: StatusMenuProps) {
<div className="font-medium ms-3 px-1.5 text-secondary">Actions</div>

<ul className="space-y-1 p-1">
<li className="font-mono">
<button
type="button"
className="w-full rounded-lg flex gap-2 items-center bg-transparent py-2 px-3 hover:bg-neutral-200/80 dark:hover:bg-neutral-800/20"
onClick={() => {
pin.mutate();
popover.setOpen(false);
}}
>
<div
className={
task.pinned ? "i-solar-pin-broken" : "i-solar-pin-linear"
}
/>
{task.pinned ? "Unpin" : "Pin"}
</button>
</li>
<li className="font-mono">
{confirmingDelete ? (
<div className="flex justify-between items-center px-3 py-2 text-red-500 rounded-lg bg-red-100 dark:bg-red-800/10">
<span className="flex items-center gap-2">
<div className="i-solar-trash-bin-trash-linear " />
<div className="i-solar-trash-bin-trash-linear" />
Delete?
</span>
<div className="flex gap-2">
Expand Down
3 changes: 3 additions & 0 deletions app/components/todo-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export function TodoItem({ task }: Props) {
</div>
</div>

{task.pinned && (
<div className="i-solar-pin-circle-broken text-lg text-secondary" />
)}
<div className="flex gap-3 items-center">
<Assignee task={task} />

Expand Down
32 changes: 31 additions & 1 deletion app/lib/send-discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ async function createWebhookPayload(

if (project) {
embed.footer = {
text: `📌 ${project?.name}`,
text: `📦 ${project?.name}`,
};
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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":
Expand Down
41 changes: 41 additions & 0 deletions app/lib/use-task-pin.ts
Original file line number Diff line number Diff line change
@@ -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<Task> {
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;
}
8 changes: 8 additions & 0 deletions app/lib/webhook-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type EventType =
| "task.deleted"
| "task.status_changed"
| "task.assigned"
| "task.pinned"
| "comment.created"
| "user.joined";

Expand Down Expand Up @@ -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;
Expand Down
22 changes: 19 additions & 3 deletions app/routes/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down Expand Up @@ -110,13 +115,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
assigneeId: true,
status: true,
title: true,
pinned: true,
},
});

if (!previous) throw notFound();

const previousAssigneeId = previous.assigneeId;
const previousStatus = previous.status;
const previousPinned = previous.pinned;

const task = await prisma.task.update({
where: { id },
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions prisma/migrations/20250608090308_pin_tasks/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down