diff --git a/bun.lock b/bun.lock index 54ee4cd..e66aa34 100644 --- a/bun.lock +++ b/bun.lock @@ -53,12 +53,14 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.561.0", + "next-themes": "^0.4.6", "react": "^19.1.0", "react-colorful": "^5.6.1", "react-day-picker": "^9.13.0", "react-dom": "^19.1.0", "react-resizable-panels": "^4.0.15", "react-router-dom": "^7.10.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", }, @@ -617,6 +619,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], @@ -695,6 +699,8 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e4fb32a..80177b9 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -25,12 +25,14 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.561.0", + "next-themes": "^0.4.6", "react": "^19.1.0", "react-colorful": "^5.6.1", "react-day-picker": "^9.13.0", "react-dom": "^19.1.0", "react-resizable-panels": "^4.0.15", "react-router-dom": "^7.10.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18" }, diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css index 317b399..3194cc0 100644 --- a/packages/frontend/src/App.css +++ b/packages/frontend/src/App.css @@ -214,3 +214,13 @@ height: 16px !important; border: 1px solid white !important; } + +[data-sonner-toast] { + transition: none !important; + padding: 10px 15px 10px 15px !important; + width: max-content !important; + max-width: 90vw !important; + display: inline-flex !important; + align-items: center !important; + white-space: nowrap !important; +} diff --git a/packages/frontend/src/components/account-dialog.tsx b/packages/frontend/src/components/account-dialog.tsx index 2458615..a1bafe6 100644 --- a/packages/frontend/src/components/account-dialog.tsx +++ b/packages/frontend/src/components/account-dialog.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { useAuthenticatedSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; @@ -54,6 +55,10 @@ function AccountDialog({ trigger }: { trigger?: ReactNode }) { setError(errorMessage); }, }); + + toast.success(`Account updated successfully`, { + dismissible: false, + }); }; return ( diff --git a/packages/frontend/src/components/add-member-dialog.tsx b/packages/frontend/src/components/add-member-dialog.tsx index e2c5f74..3f5ee89 100644 --- a/packages/frontend/src/components/add-member-dialog.tsx +++ b/packages/frontend/src/components/add-member-dialog.tsx @@ -1,3 +1,4 @@ +import type { UserRecord } from "@issue/shared"; import { type FormEvent, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -20,7 +21,7 @@ export function AddMemberDialog({ organisationId: number; existingMembers: string[]; trigger?: React.ReactNode; - onSuccess?: () => void | Promise; + onSuccess?: (user: UserRecord) => void | Promise; }) { const [open, setOpen] = useState(false); const [username, setUsername] = useState(""); @@ -59,10 +60,12 @@ export function AddMemberDialog({ setSubmitting(true); try { let userId: number | null = null; + let userData: UserRecord; await user.byUsername({ username, - onSuccess: (userData) => { - userId = userData.id; + onSuccess: (data: UserRecord) => { + userData = data; + userId = data.id; }, onError: (err) => { setError(err || "user not found"); @@ -82,7 +85,7 @@ export function AddMemberDialog({ setOpen(false); reset(); try { - await onSuccess?.(); + await onSuccess?.(userData); } catch (actionErr) { console.error(actionErr); } diff --git a/packages/frontend/src/components/create-issue.tsx b/packages/frontend/src/components/create-issue.tsx index 76ddcd3..b817573 100644 --- a/packages/frontend/src/components/create-issue.tsx +++ b/packages/frontend/src/components/create-issue.tsx @@ -39,7 +39,7 @@ export function CreateIssue({ members?: UserRecord[]; statuses: Record; trigger?: React.ReactNode; - completeAction?: (issueId: number) => void | Promise; + completeAction?: (issueNumber: number) => void | Promise; }) { const { user } = useAuthenticatedSession(); @@ -108,7 +108,7 @@ export function CreateIssue({ setOpen(false); reset(); try { - await completeAction?.(data.id); + await completeAction?.(data.number); } catch (actionErr) { console.error(actionErr); } diff --git a/packages/frontend/src/components/create-organisation.tsx b/packages/frontend/src/components/create-organisation.tsx index 893d321..75d1b43 100644 --- a/packages/frontend/src/components/create-organisation.tsx +++ b/packages/frontend/src/components/create-organisation.tsx @@ -1,4 +1,9 @@ -import { ORG_DESCRIPTION_MAX_LENGTH, ORG_NAME_MAX_LENGTH, ORG_SLUG_MAX_LENGTH } from "@issue/shared"; +import { + ORG_DESCRIPTION_MAX_LENGTH, + ORG_NAME_MAX_LENGTH, + ORG_SLUG_MAX_LENGTH, + type OrganisationRecord, +} from "@issue/shared"; import { type FormEvent, useState } from "react"; import { useAuthenticatedSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; @@ -28,7 +33,7 @@ export function CreateOrganisation({ completeAction, }: { trigger?: React.ReactNode; - completeAction?: (organisationId: number) => void | Promise; + completeAction?: (org: OrganisationRecord) => void | Promise; }) { const { user } = useAuthenticatedSession(); @@ -83,7 +88,7 @@ export function CreateOrganisation({ setOpen(false); reset(); try { - await completeAction?.(data.id); + await completeAction?.(data); } catch (actionErr) { console.error(actionErr); } diff --git a/packages/frontend/src/components/create-project.tsx b/packages/frontend/src/components/create-project.tsx index 0a468a4..e6b2bcb 100644 --- a/packages/frontend/src/components/create-project.tsx +++ b/packages/frontend/src/components/create-project.tsx @@ -28,7 +28,7 @@ export function CreateProject({ }: { organisationId?: number; trigger?: React.ReactNode; - completeAction?: (projectId: number) => void | Promise; + completeAction?: (project: ProjectRecord) => void | Promise; }) { const { user } = useAuthenticatedSession(); @@ -93,7 +93,7 @@ export function CreateProject({ setOpen(false); reset(); try { - await completeAction?.(project.id); + await completeAction?.(project); } catch (actionErr) { console.error(actionErr); } diff --git a/packages/frontend/src/components/create-sprint.tsx b/packages/frontend/src/components/create-sprint.tsx index 927dcb8..b022b62 100644 --- a/packages/frontend/src/components/create-sprint.tsx +++ b/packages/frontend/src/components/create-sprint.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_SPRINT_COLOUR } from "@issue/shared"; +import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@issue/shared"; import { type FormEvent, useMemo, useState } from "react"; import { useAuthenticatedSession } from "@/components/session-provider"; import { Button } from "@/components/ui/button"; @@ -53,7 +53,7 @@ export function CreateSprint({ }: { projectId?: number; trigger?: React.ReactNode; - completeAction?: () => void | Promise; + completeAction?: (sprint: SprintRecord) => void | Promise; }) { const { user } = useAuthenticatedSession(); @@ -122,14 +122,14 @@ export function CreateSprint({ await sprint.create({ projectId, name, - color: colour, // hm - always unsure which i should use + color: colour, // hm - always unsure which i should use startDate, endDate, - onSuccess: async () => { + onSuccess: async (data) => { setOpen(false); reset(); try { - await completeAction?.(); + await completeAction?.(data); } catch (actionErr) { console.error(actionErr); } diff --git a/packages/frontend/src/components/issue-detail-pane.tsx b/packages/frontend/src/components/issue-detail-pane.tsx index 0def8fa..f2a1f9e 100644 --- a/packages/frontend/src/components/issue-detail-pane.tsx +++ b/packages/frontend/src/components/issue-detail-pane.tsx @@ -1,6 +1,7 @@ import type { IssueResponse, ProjectResponse, SprintRecord, UserRecord } from "@issue/shared"; import { Check, Link, Trash, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { useSession } from "@/components/session-provider"; import SmallUserDisplay from "@/components/small-user-display"; import { StatusSelect } from "@/components/status-select"; @@ -83,6 +84,13 @@ export function IssueDetailPane({ issueId: issueData.Issue.id, assigneeId: newAssigneeId, onSuccess: () => { + const user = members.find((member) => member.id === newAssigneeId); + toast.success( + `Assigned ${user?.name} to ${issueID(project.Project.key, issueData.Issue.number)}`, + { + dismissible: false, + }, + ); onIssueUpdate?.(); }, onError: (error) => { @@ -99,6 +107,13 @@ export function IssueDetailPane({ issueId: issueData.Issue.id, status: value, onSuccess: () => { + toast.success( + <> + {issueID(project.Project.key, issueData.Issue.number)}'s status updated to{" "} + + , + { dismissible: false }, + ); onIssueUpdate?.(); }, onError: (error) => { diff --git a/packages/frontend/src/components/organisation-select.tsx b/packages/frontend/src/components/organisation-select.tsx index 5bdc98e..a3235af 100644 --- a/packages/frontend/src/components/organisation-select.tsx +++ b/packages/frontend/src/components/organisation-select.tsx @@ -1,4 +1,4 @@ -import type { OrganisationResponse } from "@issue/shared"; +import type { OrganisationRecord, OrganisationResponse } from "@issue/shared"; import { useState } from "react"; import { CreateOrganisation } from "@/components/create-organisation"; import { Button } from "@/components/ui/button"; @@ -27,7 +27,7 @@ export function OrganisationSelect({ organisations: OrganisationResponse[]; selectedOrganisation: OrganisationResponse | null; onSelectedOrganisationChange: (organisation: OrganisationResponse | null) => void; - onCreateOrganisation?: (organisationId: number) => void | Promise; + onCreateOrganisation?: (org: OrganisationRecord) => void | Promise; placeholder?: string; contentClass?: string; showLabel?: boolean; @@ -79,9 +79,9 @@ export function OrganisationSelect({ Create Organisation } - completeAction={async (organisationId) => { + completeAction={async (org) => { try { - await onCreateOrganisation?.(organisationId); + await onCreateOrganisation?.(org); } catch (err) { console.error(err); } diff --git a/packages/frontend/src/components/organisations-dialog.tsx b/packages/frontend/src/components/organisations-dialog.tsx index 9b63d5c..8c35cb2 100644 --- a/packages/frontend/src/components/organisations-dialog.tsx +++ b/packages/frontend/src/components/organisations-dialog.tsx @@ -7,6 +7,7 @@ import { import { ChevronDown, ChevronUp, EllipsisVertical, Plus, X } from "lucide-react"; import type { ReactNode } from "react"; import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; import { AddMemberDialog } from "@/components/add-member-dialog"; import { OrganisationSelect } from "@/components/organisation-select"; import { useAuthenticatedSession } from "@/components/session-provider"; @@ -26,6 +27,7 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { issue, organisation } from "@/lib/server"; +import { capitalise } from "@/lib/utils"; function OrganisationsDialog({ trigger, @@ -129,6 +131,10 @@ function OrganisationsDialog({ console.error(error); }, }); + + toast.success(`${capitalise(action)}d ${memberName} to ${newRole} successfully`, { + dismissible: false, + }); } catch (err) { console.error(err); } @@ -162,6 +168,13 @@ function OrganisationsDialog({ console.error(error); }, }); + + toast.success( + `Removed ${memberName} from ${selectedOrganisation.Organisation.name} successfully`, + { + dismissible: false, + }, + ); } catch (err) { console.error(err); } @@ -175,7 +188,10 @@ function OrganisationsDialog({ } }, [selectedOrganisation]); - const updateStatuses = async (newStatuses: Record) => { + const updateStatuses = async ( + newStatuses: Record, + statusRemoved?: { name: string; colour: string }, + ) => { if (!selectedOrganisation) return; try { @@ -184,6 +200,18 @@ function OrganisationsDialog({ statuses: newStatuses, onSuccess: () => { setStatuses(newStatuses); + if (statusRemoved) { + toast.success( + <> + Removed{" "} + status + successfully + , + { + dismissible: false, + }, + ); + } void refetchOrganisations(); }, onError: (error) => { @@ -213,6 +241,17 @@ function OrganisationsDialog({ const newStatuses = { ...statuses }; newStatuses[trimmed] = newStatusColour; await updateStatuses(newStatuses); + + toast.success( + <> + Created status + successfully + , + { + dismissible: false, + }, + ); + setNewStatusName(""); setNewStatusColour(DEFAULT_STATUS_COLOUR); setIsCreatingStatus(false); @@ -238,6 +277,7 @@ function OrganisationsDialog({ const nextStatuses = Object.keys(statuses).filter((s) => s !== status); void updateStatuses( Object.fromEntries(nextStatuses.map((statusKey) => [statusKey, statuses[statusKey]])), + { name: status, colour: statuses[status] }, ); }, onError: (error) => { @@ -262,6 +302,15 @@ function OrganisationsDialog({ ]; await updateStatuses(Object.fromEntries(nextStatuses.map((status) => [status, statuses[status]]))); + toast.success( + <> + Moved from position {currentIndex + 1}{" "} + to {nextIndex + 1} successfully + , + { + dismissible: false, + }, + ); }; const confirmRemoveStatus = async () => { @@ -275,6 +324,7 @@ function OrganisationsDialog({ const newStatuses = Object.keys(statuses).filter((s) => s !== statusToRemove); await updateStatuses( Object.fromEntries(newStatuses.map((status) => [status, statuses[status]])), + { name: statusToRemove, colour: statuses[statusToRemove] }, ); setStatusToRemove(null); setReassignToStatus(""); @@ -319,8 +369,11 @@ function OrganisationsDialog({ `${org?.Organisation.id}`, ); }} - onCreateOrganisation={async (organisationId) => { - await refetchOrganisations({ selectOrganisationId: organisationId }); + onCreateOrganisation={async (org) => { + toast.success(`Created Organisation ${org.name}`, { + dismissible: false, + }); + await refetchOrganisations({ selectOrganisationId: org.id }); }} contentClass={ "data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25" @@ -422,7 +475,15 @@ function OrganisationsDialog({ m.User.username)} - onSuccess={refetchMembers} + onSuccess={(user) => { + toast.success( + `${user.name} added to ${selectedOrganisation.Organisation.name} successfully`, + { + dismissible: false, + }, + ); + refetchMembers(); + }} trigger={ } - completeAction={async (projectId) => { + completeAction={async (project) => { try { - await onCreateProject?.(projectId); + await onCreateProject?.(project); } catch (err) { console.error(err); } diff --git a/packages/frontend/src/components/ui/sonner.tsx b/packages/frontend/src/components/ui/sonner.tsx new file mode 100644 index 0000000..1952ed9 --- /dev/null +++ b/packages/frontend/src/components/ui/sonner.tsx @@ -0,0 +1,32 @@ +import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react"; +import { useTheme } from "next-themes"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "0", + } as React.CSSProperties + } + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/packages/frontend/src/components/upload-avatar.tsx b/packages/frontend/src/components/upload-avatar.tsx index fdc6301..59d82a4 100644 --- a/packages/frontend/src/components/upload-avatar.tsx +++ b/packages/frontend/src/components/upload-avatar.tsx @@ -1,5 +1,6 @@ import { Edit } from "lucide-react"; import { useRef, useState } from "react"; +import { toast } from "sonner"; import Avatar from "@/components/avatar"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -43,6 +44,10 @@ export function UploadAvatar({ setUploading(false); }, }); + + toast.success(`Avatar uploaded successfully`, { + dismissible: false, + }); }; return ( diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index c895256..af93eef 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -4,6 +4,7 @@ import ReactDOM from "react-dom/client"; import { BrowserRouter, Route, Routes } from "react-router-dom"; import { RequireAuth, SessionProvider } from "@/components/session-provider"; import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; import App from "@/pages/App"; import Font from "@/pages/Font"; import Landing from "@/pages/Landing"; @@ -44,6 +45,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + , ); diff --git a/packages/frontend/src/pages/App.tsx b/packages/frontend/src/pages/App.tsx index 92de73e..0c0ee2c 100644 --- a/packages/frontend/src/pages/App.tsx +++ b/packages/frontend/src/pages/App.tsx @@ -9,6 +9,7 @@ import type { UserRecord, } from "@issue/shared"; import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import AccountDialog from "@/components/account-dialog"; import { CreateIssue } from "@/components/create-issue"; import { CreateSprint } from "@/components/create-sprint"; @@ -32,6 +33,7 @@ import { } from "@/components/ui/dropdown-menu"; import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable"; import { issue, organisation, project, sprint } from "@/lib/server"; +import { issueID } from "@/lib/utils"; const BREATHING_ROOM = 1; @@ -385,8 +387,11 @@ export default function App() { issueNumber: null, }); }} - onCreateOrganisation={async (organisationId) => { - await refetchOrganisations({ selectOrganisationId: organisationId }); + onCreateOrganisation={async (org) => { + toast.success(`Created Organisation ${org.name}`, { + dismissible: false, + }); + await refetchOrganisations({ selectOrganisationId: org.id }); }} showLabel /> @@ -406,10 +411,13 @@ export default function App() { issueNumber: null, }); }} - onCreateProject={async (projectId) => { + onCreateProject={async (project) => { if (!selectedOrganisation) return; + toast.success(`Created Project ${project.name}`, { + dismissible: false, + }); await refetchProjects(selectedOrganisation.Organisation.id, { - selectProjectId: projectId, + selectProjectId: project.id, }); }} showLabel @@ -422,16 +430,31 @@ export default function App() { sprints={sprints} members={members} statuses={selectedOrganisation.Organisation.statuses} - completeAction={async () => { + completeAction={async (issueNumber) => { if (!selectedProject) return; + toast.success( + `Created ${issueID(selectedProject.Project.key, issueNumber)}`, + { + dismissible: false, + }, + ); await refetchIssues(); }} /> {isAdmin && ( { + completeAction={async (sprint) => { if (!selectedProject) return; + toast.success( + <> + Created sprint{" "} + {sprint.name} + , + { + dismissible: false, + }, + ); await refetchSprints(selectedProject?.Project.id); }} /> diff --git a/todo.md b/todo.md index afc4c56..f567115 100644 --- a/todo.md +++ b/todo.md @@ -1,13 +1,11 @@ # HIGH PRIORITY +- fix colour picker alignment in new status form - projects - project management menu (will this be accessed from the organisations-dialog? or will it be a separate menu in the user select) - sprints - timeline display - display sprints -- add toasts app-wide - - for almost every network interaction that is user prompted - - the interface feels snappy but sometimes it's hard to tell if your changes are volatile or saved - issues - sprint - edit title & description