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
11 changes: 5 additions & 6 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,11 @@
});

$effect(() => {
const syncRecent = () => {
const handler = () => {
trackRecentFromPath(window.location.hash.slice(1) || "/");
};
syncRecent();
window.addEventListener("hashchange", syncRecent);
return () => window.removeEventListener("hashchange", syncRecent);
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
});

const routes: RouteDef[] = [
Expand All @@ -66,9 +65,9 @@
{ pattern: "/dashboard/all", component: DashboardTasks, auth: true },
{ pattern: "/projects", component: ProjectsPage, auth: true },
{ pattern: "/projects/:slug", component: ProjectTasks, auth: true },
{ pattern: "/task/:slug/:number/edit", component: TaskEditPage, auth: true },
{ pattern: "/task/:slug/:number", component: TaskDetailPage, auth: true },
{ pattern: "/tasks/new", component: TaskNewPage, auth: true },
{ pattern: "/tasks/:id/edit", component: TaskEditPage, auth: true },
{ pattern: "/tasks/:id", component: TaskDetailPage, auth: true },
{ pattern: "/profile", component: ProfilePage, auth: true },
{ pattern: "/settings/security", component: SecurityPage, auth: true },
{ pattern: "/admin/users", component: AdminUsers, auth: true, role: "admin" },
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/api/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export function getTask(id: number): Promise<TaskDetails> {
return apiRequest<TaskDetails>('GET', `/tasks/${id}`)
}

export function getTaskByProjectAndNumber(slug: string, number: number): Promise<TaskDetails> {
return apiRequest<TaskDetails>('GET', `/tasks/${slug}/${number}`)
}

export function createTask(data: TaskCreateRequest): Promise<TaskDetails> {
return apiRequest<TaskDetails>('POST', '/tasks', data)
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/pages/dashboard-personal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
}

function handleTaskClick(task: Task) {
navigate(`/tasks/${task.id}`);
navigate(`/task/${task.project_slug}/${task.number}`);
}

$effect(() => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/pages/dashboard-tasks.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
$effect(load);

function handleTaskClick(task: Task) {
navigate(`/tasks/${task.id}`);
navigate(`/task/${task.project_slug}/${task.number}`);
}
</script>

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/pages/project-tasks.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
}

function handleTaskClick(task: Task) {
navigate(`/tasks/${task.id}`);
navigate(`/task/${task.project_slug}/${task.number}`);
}
</script>

Expand Down
48 changes: 31 additions & 17 deletions frontend/src/lib/pages/task-detail.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { getTask, deleteTask, updateTask } from "$lib/api/tasks";
import { getTaskByProjectAndNumber, deleteTask, updateTask } from "$lib/api/tasks";
import {
createComment,
updateComment,
Expand All @@ -20,17 +20,18 @@
import * as Card from "$lib/components/ui/card";
import { parse } from "marked";
import DOMPurify from "dompurify";
import { onMount } from "svelte";
import type { TaskDetails, Comment, Attachment } from "$lib/types/api";
import { STATUSES } from "$lib/types/api";
import { processAutoLinks } from "$lib/autolink";
import type { AutoLinkContext } from "$lib/autolink";
import { toast } from "$lib/toast";

let { params = {} }: { params?: Record<string, string> } = $props();
let id = $derived(Number(params.id));
let slug = $derived(params.slug || "");
let number = $derived(Number(params.number));

let task = $state<TaskDetails | null>(null);
let taskId = $state(0);
let loading = $state(true);
let error = $state("");

Expand All @@ -41,7 +42,7 @@
let savingStatus = $state(false);

let assigneeId = $state<number | null>(null);
let assigning = $state(false);
let assigning = false;
let ready = $state(false);

let currentUserId = $derived(getUser()?.id);
Expand All @@ -60,20 +61,33 @@
: "",
);

onMount(() => {
if (!id) return;
getTask(id)
$effect(() => {
const currentSlug = slug;
const currentNumber = number;
if (!currentSlug || !currentNumber) return;

loading = true;
error = "";
task = null;
ready = false;
taskId = 0;

getTaskByProjectAndNumber(currentSlug, currentNumber)
.then((t) => {
if (currentSlug !== slug || currentNumber !== number) return;
task = t;
taskId = t.id;
selectedStatus = t.status;
assigneeId = t.assignee?.id ?? null;
ready = true;
touchProject({ id: t.project.id, name: t.project.name });
})
.catch((e) => {
if (currentSlug !== slug || currentNumber !== number) return;
error = e.message || "Failed to load task";
})
.finally(() => {
if (currentSlug !== slug || currentNumber !== number) return;
loading = false;
});
});
Expand All @@ -84,7 +98,7 @@
const oldId = task.assignee?.id ?? null;
if (newId !== oldId) {
assigning = true;
updateTask(id, { assignee_id: newId ?? 0 })
updateTask(taskId, { assignee_id: newId ?? 0 })
.then((updated) => {
task = updated;
assigneeId = updated.assignee?.id ?? null;
Expand All @@ -103,7 +117,7 @@
if (!confirm("Delete this task permanently?")) return;
deleting = true;
try {
await deleteTask(id);
await deleteTask(taskId);
navigate("/dashboard");
} catch (e: any) {
toast.error(e.message || "Failed to delete task");
Expand All @@ -113,15 +127,15 @@
}

async function handleAddComment(content: string) {
const created = await createComment(id, { content });
const created = await createComment(taskId, { content });
task = {
...task!,
comments: [...task!.comments, created as Comment],
};
}

async function handleEditComment(commentId: number, content: string) {
const updated = await updateComment(id, commentId, { content });
const updated = await updateComment(taskId, commentId, { content });
task = {
...task!,
comments: task!.comments.map((c) =>
Expand All @@ -131,15 +145,15 @@
}

async function handleDeleteComment(commentId: number) {
await deleteComment(id, commentId);
await deleteComment(taskId, commentId);
task = {
...task!,
comments: task!.comments.filter((c) => c.id !== commentId),
};
}

async function handleDeleteAttachment(attachmentId: number) {
await deleteAttachment(id, attachmentId);
await deleteAttachment(taskId, attachmentId);
task = {
...task!,
attachments: task!.attachments.filter((a) => a.id !== attachmentId),
Expand All @@ -149,7 +163,7 @@
async function handleStatusChange() {
savingStatus = true;
try {
const updated = await updateTask(id, {
const updated = await updateTask(taskId, {
status: selectedStatus,
comment: statusComment || undefined,
});
Expand Down Expand Up @@ -197,7 +211,7 @@
<h1 class="text-2xl font-semibold">{task.title}</h1>
</div>
<div class="flex shrink-0 gap-2">
<Button variant="outline" onclick={() => navigate(`/tasks/${id}/edit`)}
<Button variant="outline" onclick={() => navigate(`/task/${slug}/${number}/edit`)}
>Edit</Button
>
<Button
Expand Down Expand Up @@ -352,11 +366,11 @@
onDelete={handleDeleteAttachment}
/>
<AttachmentUpload
taskId={id}
taskId={taskId}
onUploaded={(confirmed) => {
const att: Attachment = {
id: confirmed.id,
task_id: id,
task_id: taskId,
uploaded_by: confirmed.uploaded_by,
file_name: confirmed.file_name,
size_bytes: confirmed.size_bytes,
Expand Down
32 changes: 24 additions & 8 deletions frontend/src/lib/pages/task-edit.svelte
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
<script lang="ts">
import { getTask, updateTask } from "$lib/api/tasks";
import { getTaskByProjectAndNumber, updateTask } from "$lib/api/tasks";
import { listProjects } from "$lib/api/projects";
import { navigate } from "$lib/router/routes";
import TaskForm from "$lib/components/TaskForm.svelte";
import type { TaskDetails, Project } from "$lib/types/api";
import { onMount } from "svelte";

let { params = {} }: { params?: Record<string, string> } = $props();
let id = $derived(Number(params.id));
let slug = $derived(params.slug || "");
let number = $derived(Number(params.number));

let task = $state<TaskDetails | null>(null);
let taskId = $state(0);
let projects = $state<Project[]>([]);
let loading = $state(true);
let error = $state("");

onMount(() => {
if (!id) return;
Promise.all([getTask(id), listProjects(100, 0)])
$effect(() => {
const currentSlug = slug;
const currentNumber = number;
if (!currentSlug || !currentNumber) return;

loading = true;
error = "";
task = null;
taskId = 0;

Promise.all([
getTaskByProjectAndNumber(currentSlug, currentNumber),
listProjects(100, 0),
])
.then(([t, p]) => {
if (currentSlug !== slug || currentNumber !== number) return;
task = t;
taskId = t.id;
projects = p.items;
})
.catch((e) => {
if (currentSlug !== slug || currentNumber !== number) return;
error = e.message || "Failed to load task";
})
.finally(() => {
if (currentSlug !== slug || currentNumber !== number) return;
loading = false;
});
});
Expand All @@ -38,7 +54,7 @@
assignee_id: number | null | undefined;
due_date: string;
}) {
const updated = await updateTask(id, {
const updated = await updateTask(taskId, {
title: data.title,
description: data.description || undefined,
priority: data.priority as any,
Expand All @@ -47,7 +63,7 @@
assignee_id: data.assignee_id ?? undefined,
due_date: data.due_date,
});
navigate(`/tasks/${updated.id}`);
navigate(`/task/${updated.project_slug}/${updated.number}`);
}
</script>

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/pages/task-new.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
id: data.project_slug,
name: match?.name || data.project_slug,
});
navigate(`/tasks/${task.id}`);
navigate(`/task/${encodeURIComponent(task.project_slug)}/${task.number}`);
}
</script>

Expand All @@ -69,7 +69,7 @@
<TaskForm
mode="create"
{projects}
initialProjectSlug={initialProjectSlug}
{initialProjectSlug}
onSubmit={handleSubmit}
/>
{/if}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/stores/recent-projects.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ export function parseProjectSlugFromPath(path: string): string {
const legacyMatch = base.match(/^\/projects\/([^/]+)\/tasks$/)
if (legacyMatch) return decodeURIComponent(legacyMatch[1] || '').trim()

const taskMatch = base.match(/^\/task\/([^/]+)\/\d+$/)
if (taskMatch) return decodeURIComponent(taskMatch[1] || '').trim()

const taskEditMatch = base.match(/^\/task\/([^/]+)\/\d+\/edit$/)
if (taskEditMatch) return decodeURIComponent(taskEditMatch[1] || '').trim()

if (base.startsWith('/tasks/new/')) {
return decodeURIComponent(base.slice('/tasks/new/'.length) || '').trim()
}
Expand Down
62 changes: 62 additions & 0 deletions internal/server/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,68 @@ const docTemplate = `{
}
}
},
"/tasks/{slug}/{number}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns detailed information about a task identified by its project slug and sequential number.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Tasks"
],
"summary": "Get task by project slug and number",
"parameters": [
{
"type": "string",
"description": "Project slug",
"name": "slug",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Task number within project",
"name": "number",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/tasks.TaskDetailsResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/fiberfx.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/fiberfx.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/fiberfx.ErrorResponse"
}
}
}
}
},
"/tasks/{task_id}/comments": {
"post": {
"security": [
Expand Down
Loading
Loading