From 5160a6a5546433c3c7819b587bcbaa54d96aa1ec Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Sat, 3 Jan 2026 12:42:39 +0000 Subject: [PATCH] converted account and organisation pages to dialogs --- packages/frontend/src/Account.tsx | 109 -------- packages/frontend/src/App.tsx | 4 - packages/frontend/src/Index.tsx | 56 +++- packages/frontend/src/Organisations.tsx | 242 ------------------ .../src/components/account-dialog.tsx | 130 ++++++++++ .../src/components/create-organisation.tsx | 27 +- packages/frontend/src/components/header.tsx | 62 ----- .../src/components/organisation-select.tsx | 4 +- .../src/components/organisations-dialog.tsx | 227 ++++++++++++++++ .../src/components/ui/dropdown-menu.tsx | 1 - packages/frontend/src/components/ui/field.tsx | 10 +- 11 files changed, 441 insertions(+), 431 deletions(-) delete mode 100644 packages/frontend/src/Account.tsx delete mode 100644 packages/frontend/src/Organisations.tsx create mode 100644 packages/frontend/src/components/account-dialog.tsx delete mode 100644 packages/frontend/src/components/header.tsx create mode 100644 packages/frontend/src/components/organisations-dialog.tsx diff --git a/packages/frontend/src/Account.tsx b/packages/frontend/src/Account.tsx deleted file mode 100644 index 9d8f1b0..0000000 --- a/packages/frontend/src/Account.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import type { UserRecord } from "@issue/shared"; -import { useEffect, useState } from "react"; -import { SettingsPageLayout } from "@/components/settings-page-layout"; -import { Button } from "@/components/ui/button"; -import { Field } from "@/components/ui/field"; -import { Label } from "@/components/ui/label"; -import { UploadAvatar } from "@/components/upload-avatar"; -import { user } from "@/lib/server"; - -function Account() { - const [name, setName] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [avatarURL, setAvatarUrl] = useState(null); - const [error, setError] = useState(""); - const [submitAttempted, setSubmitAttempted] = useState(false); - const [userId, setUserId] = useState(null); - - useEffect(() => { - const userStr = localStorage.getItem("user"); - if (userStr) { - const user = JSON.parse(userStr) as UserRecord; - setName(user.name); - setUsername(user.username); - setUserId(user.id); - setAvatarUrl(user.avatarURL || null); - } - }, []); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setSubmitAttempted(true); - - if (name.trim() === "") { - return; - } - - if (!userId) { - setError("User not found"); - return; - } - - await user.update({ - id: userId, - name: name.trim(), - password: password.trim(), - avatarURL: avatarURL, - onSuccess: (data) => { - setError(""); - localStorage.setItem("user", JSON.stringify(data)); - setPassword(""); - window.location.reload(); - }, - onError: (errorMessage) => { - setError(errorMessage); - }, - }); - }; - - return ( - -
-

Account Details

- - {avatarURL && ( - - )} - setName(e.target.value)} - validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} - submitAttempted={submitAttempted} - /> - setPassword(e.target.value)} - placeholder="Leave empty to keep current password" - hidden={true} - /> - - {error !== "" && } - -
- -
- -
- ); -} - -export default Account; diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index b1b9403..6b0a21c 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,10 +1,8 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; -import Account from "@/Account"; import { Auth } from "@/components/auth-provider"; import NotFound from "@/components/NotFound"; import { ThemeProvider } from "@/components/theme-provider"; import Index from "@/Index"; -import Organisations from "@/Organisations"; import Test from "@/Test"; function App() { @@ -14,8 +12,6 @@ function App() { } /> - } /> - } /> } /> } /> diff --git a/packages/frontend/src/Index.tsx b/packages/frontend/src/Index.tsx index 0f2d164..2d71ed5 100644 --- a/packages/frontend/src/Index.tsx +++ b/packages/frontend/src/Index.tsx @@ -1,12 +1,24 @@ /** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */ import type { IssueResponse, OrganisationResponse, ProjectResponse, UserRecord } from "@issue/shared"; import { useEffect, useRef, useState } from "react"; +import AccountDialog from "@/components/account-dialog"; import { CreateIssue } from "@/components/create-issue"; -import Header from "@/components/header"; import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssuesTable } from "@/components/issues-table"; +import LogOutButton from "@/components/log-out-button"; import { OrganisationSelect } from "@/components/organisation-select"; +import OrganisationsDialog from "@/components/organisations-dialog"; import { ProjectSelect } from "@/components/project-select"; +import { ServerConfigurationDialog } from "@/components/server-configuration-dialog"; +import SmallUserDisplay from "@/components/small-user-display"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable"; import { issue, organisation, project } from "@/lib/server"; @@ -157,7 +169,7 @@ function Index() { return (
{/* header area */} -
+
{/* organisation selection */} )}
-
+
+ + + + + + + + + + + + + + Server Configuration + + } + /> + + + + + + + +
+ + {/* main body */}
{selectedProject && issues.length > 0 && ( diff --git a/packages/frontend/src/Organisations.tsx b/packages/frontend/src/Organisations.tsx deleted file mode 100644 index 3395a79..0000000 --- a/packages/frontend/src/Organisations.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import type { OrganisationMemberResponse, OrganisationResponse, UserRecord } from "@issue/shared"; -import { Plus, X } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { AddMemberDialog } from "@/components/add-member-dialog"; -import { OrganisationSelect } from "@/components/organisation-select"; -import { SettingsPageLayout } from "@/components/settings-page-layout"; -import SmallUserDisplay from "@/components/small-user-display"; -import { Button } from "@/components/ui/button"; -import { ConfirmDialog } from "@/components/ui/confirm-dialog"; -import { organisation } from "@/lib/server"; - -function Organisations() { - const user = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord; - - const [organisations, setOrganisations] = useState([]); - const [selectedOrganisation, setSelectedOrganisation] = useState(null); - const [members, setMembers] = useState([]); - const [confirmDialog, setConfirmDialog] = useState<{ - open: boolean; - memberUserId: number; - memberName: string; - }>({ open: false, memberUserId: 0, memberName: "" }); - - const refetchOrganisations = useCallback( - async (options?: { selectOrganisationId?: number }) => { - try { - await organisation.byUser({ - userId: user.id, - onSuccess: (data) => { - const organisations = data as OrganisationResponse[]; - setOrganisations(organisations); - - let selected: OrganisationResponse | null = null; - - if (options?.selectOrganisationId) { - const created = organisations.find( - (o) => o.Organisation.id === options.selectOrganisationId, - ); - if (created) { - selected = created; - } - } else { - const savedId = localStorage.getItem("selectedOrganisationId"); - if (savedId) { - const saved = organisations.find( - (o) => o.Organisation.id === Number(savedId), - ); - if (saved) { - selected = saved; - } - } - } - - if (!selected) { - selected = organisations[0] || null; - } - - setSelectedOrganisation(selected); - }, - onError: (error) => { - console.error(error); - setOrganisations([]); - setSelectedOrganisation(null); - }, - }); - } catch (err) { - console.error("error fetching organisations:", err); - setOrganisations([]); - setSelectedOrganisation(null); - } - }, - [user.id], - ); - - const refetchMembers = useCallback(async () => { - if (!selectedOrganisation) return; - try { - await organisation.members({ - organisationId: selectedOrganisation.Organisation.id, - onSuccess: (data) => { - const members = data as OrganisationMemberResponse[]; - members.sort((a, b) => { - const nameCompare = a.User.name.localeCompare(b.User.name); - return nameCompare !== 0 - ? nameCompare - : b.OrganisationMember.role.localeCompare(a.OrganisationMember.role); - }); - setMembers(members); - }, - onError: (error) => { - console.error(error); - setMembers([]); - }, - }); - } catch (err) { - console.error("error fetching members:", err); - setMembers([]); - } - }, [selectedOrganisation]); - - const handleRemoveMember = async (memberUserId: number, memberName: string) => { - setConfirmDialog({ open: true, memberUserId, memberName }); - }; - - const confirmRemoveMember = async () => { - if (!selectedOrganisation) return; - - try { - await organisation.removeMember({ - organisationId: selectedOrganisation.Organisation.id, - userId: confirmDialog.memberUserId, - onSuccess: () => { - setConfirmDialog({ open: false, memberUserId: 0, memberName: "" }); - void refetchMembers(); - }, - onError: (error) => { - console.error(error); - }, - }); - } catch (err) { - console.error(err); - } - }; - - useEffect(() => { - void refetchOrganisations(); - }, [refetchOrganisations]); - - useEffect(() => { - void refetchMembers(); - }, [refetchMembers]); - - return ( - -
-
- { - setSelectedOrganisation(org); - localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`); - }} - onCreateOrganisation={async (organisationId) => { - await refetchOrganisations({ selectOrganisationId: organisationId }); - }} - /> -
- - {selectedOrganisation ? ( -
-
-

- {selectedOrganisation.Organisation.name} -

-
-

- Slug: {selectedOrganisation.Organisation.slug} -

-

- Role: {selectedOrganisation.OrganisationMember.role} -

- {selectedOrganisation.Organisation.description ? ( -

{selectedOrganisation.Organisation.description}

- ) : ( -

No description

- )} -
-
-
-

- {members.length} Member{members.length !== 1 ? "s" : ""} -

-
-
- {members.map((member) => ( -
-
- - - {member.OrganisationMember.role} - -
- {(selectedOrganisation.OrganisationMember.role === "owner" || - selectedOrganisation.OrganisationMember.role === "admin") && - member.OrganisationMember.role !== "owner" && - member.User.id !== user.id && ( - - )} -
- ))} -
- {(selectedOrganisation.OrganisationMember.role === "owner" || - selectedOrganisation.OrganisationMember.role === "admin") && ( - m.User.username)} - onSuccess={refetchMembers} - trigger={ - - } - /> - )} -
-
-
- ) : ( -

No organisations yet.

- )} - - setConfirmDialog({ ...confirmDialog, open })} - onConfirm={confirmRemoveMember} - title="Remove Member" - processingText="Removing..." - message={`Are you sure you want to remove ${confirmDialog.memberName} from this organisation?`} - confirmText="Remove" - variant="destructive" - /> -
-
- ); -} - -export default Organisations; diff --git a/packages/frontend/src/components/account-dialog.tsx b/packages/frontend/src/components/account-dialog.tsx new file mode 100644 index 0000000..ed0ed57 --- /dev/null +++ b/packages/frontend/src/components/account-dialog.tsx @@ -0,0 +1,130 @@ +import type { UserRecord } from "@issue/shared"; +import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Field } from "@/components/ui/field"; +import { Label } from "@/components/ui/label"; +import { UploadAvatar } from "@/components/upload-avatar"; +import { user } from "@/lib/server"; + +function AccountDialog({ trigger }: { trigger?: ReactNode }) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [avatarURL, setAvatarUrl] = useState(null); + const [error, setError] = useState(""); + const [submitAttempted, setSubmitAttempted] = useState(false); + const [userId, setUserId] = useState(null); + + useEffect(() => { + if (!open) return; + + const userStr = localStorage.getItem("user"); + if (userStr) { + const user = JSON.parse(userStr) as UserRecord; + setName(user.name); + setUsername(user.username); + setUserId(user.id); + setAvatarUrl(user.avatarURL || null); + } + + setPassword(""); + setError(""); + setSubmitAttempted(false); + }, [open]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitAttempted(true); + + if (name.trim() === "") { + return; + } + + if (!userId) { + setError("User not found"); + return; + } + + await user.update({ + id: userId, + name: name.trim(), + password: password.trim(), + avatarURL, + onSuccess: (data) => { + setError(""); + localStorage.setItem("user", JSON.stringify(data)); + setPassword(""); + window.location.reload(); + }, + onError: (errorMessage) => { + setError(errorMessage); + }, + }); + }; + + return ( + + + {trigger || ( + + )} + + + + + Account + + +
+ + {avatarURL && ( + + )} + setName(e.target.value)} + validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} + submitAttempted={submitAttempted} + /> + setPassword(e.target.value)} + placeholder="Leave empty to keep current password" + hidden={true} + /> + + {error !== "" && } + +
+ +
+ +
+
+ ); +} + +export default AccountDialog; diff --git a/packages/frontend/src/components/create-organisation.tsx b/packages/frontend/src/components/create-organisation.tsx index 445b488..9106c92 100644 --- a/packages/frontend/src/components/create-organisation.tsx +++ b/packages/frontend/src/components/create-organisation.tsx @@ -61,9 +61,8 @@ export function CreateOrganisation({ setError(null); setSubmitAttempted(true); - if (name.trim() === "" || slug.trim() === "") { - return; - } + if (name.trim() === "" || name.trim().length > 16) return; + if (slug.trim() === "" || slug.trim().length > 16) return; if (!userId) { setError("you must be logged in to create an organisation"); @@ -122,7 +121,13 @@ export function CreateOrganisation({ setSlug(slugify(nextName)); } }} - validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} + validate={(v) => + v.trim() === "" + ? "Cannot be empty" + : v.trim().length > 16 + ? "Too long (16 character limit)" + : undefined + } submitAttempted={submitAttempted} placeholder="Demo Organisation" /> @@ -133,7 +138,13 @@ export function CreateOrganisation({ setSlug(slugify(e.target.value)); setSlugManuallyEdited(true); }} - validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} + validate={(v) => + v.trim() === "" + ? "Cannot be empty" + : v.trim().length > 16 + ? "Too long (16 character limit)" + : undefined + } submitAttempted={submitAttempted} placeholder="demo-organisation" /> @@ -166,8 +177,10 @@ export function CreateOrganisation({ type="submit" disabled={ submitting || - (name.trim() === "" && submitAttempted) || - (slug.trim() === "" && submitAttempted) + name.trim() === "" || + name.trim().length > 16 || + slug.trim() === "" || + slug.trim().length > 16 } > {submitting ? "Creating..." : "Create"} diff --git a/packages/frontend/src/components/header.tsx b/packages/frontend/src/components/header.tsx deleted file mode 100644 index 29ab218..0000000 --- a/packages/frontend/src/components/header.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { UserRecord } from "@issue/shared"; -import { Link } from "react-router-dom"; -import LogOutButton from "@/components/log-out-button"; -import { ServerConfigurationDialog } from "@/components/server-configuration-dialog"; -import SmallUserDisplay from "@/components/small-user-display"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; - -export default function Header({ user, children }: { user: UserRecord; children?: React.ReactNode }) { - return ( -
- {children} -
- - - - - - - - Home - - - - - My Account - - - - - My Organisations - - - - - Server Configuration - - } - /> - - - - - - - -
-
- ); -} diff --git a/packages/frontend/src/components/organisation-select.tsx b/packages/frontend/src/components/organisation-select.tsx index 8e53869..18fb358 100644 --- a/packages/frontend/src/components/organisation-select.tsx +++ b/packages/frontend/src/components/organisation-select.tsx @@ -19,12 +19,14 @@ export function OrganisationSelect({ onSelectedOrganisationChange, onCreateOrganisation, placeholder = "Select Organisation", + contentClass, }: { organisations: OrganisationResponse[]; selectedOrganisation: OrganisationResponse | null; onSelectedOrganisationChange: (organisation: OrganisationResponse | null) => void; onCreateOrganisation?: (organisationId: number) => void | Promise; placeholder?: string; + contentClass?: string; }) { const [open, setOpen] = useState(false); @@ -44,7 +46,7 @@ export function OrganisationSelect({ - + Organisations {organisations.map((organisation) => ( diff --git a/packages/frontend/src/components/organisations-dialog.tsx b/packages/frontend/src/components/organisations-dialog.tsx new file mode 100644 index 0000000..134118c --- /dev/null +++ b/packages/frontend/src/components/organisations-dialog.tsx @@ -0,0 +1,227 @@ +import type { OrganisationMemberResponse, OrganisationResponse, UserRecord } from "@issue/shared"; +import { Plus, X } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { AddMemberDialog } from "@/components/add-member-dialog"; +import { OrganisationSelect } from "@/components/organisation-select"; +import SmallUserDisplay from "@/components/small-user-display"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { organisation } from "@/lib/server"; + +function OrganisationsDialog({ + trigger, + organisations, + selectedOrganisation, + setSelectedOrganisation, + refetchOrganisations, +}: { + trigger?: ReactNode; + organisations: OrganisationResponse[]; + selectedOrganisation: OrganisationResponse | null; + setSelectedOrganisation: (organisation: OrganisationResponse | null) => void; + refetchOrganisations: (options?: { selectOrganisationId?: number }) => Promise; +}) { + const user = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord; + + const [open, setOpen] = useState(false); + const [members, setMembers] = useState([]); + const [confirmDialog, setConfirmDialog] = useState<{ + open: boolean; + memberUserId: number; + memberName: string; + }>({ open: false, memberUserId: 0, memberName: "" }); + + const refetchMembers = useCallback(async () => { + if (!selectedOrganisation) return; + try { + await organisation.members({ + organisationId: selectedOrganisation.Organisation.id, + onSuccess: (data) => { + const members = data as OrganisationMemberResponse[]; + members.sort((a, b) => { + const nameCompare = a.User.name.localeCompare(b.User.name); + return nameCompare !== 0 + ? nameCompare + : b.OrganisationMember.role.localeCompare(a.OrganisationMember.role); + }); + setMembers(members); + }, + onError: (error) => { + console.error(error); + setMembers([]); + }, + }); + } catch (err) { + console.error("error fetching members:", err); + setMembers([]); + } + }, [selectedOrganisation]); + + const handleRemoveMember = async (memberUserId: number, memberName: string) => { + setConfirmDialog({ open: true, memberUserId, memberName }); + }; + + const confirmRemoveMember = async () => { + if (!selectedOrganisation) return; + + try { + await organisation.removeMember({ + organisationId: selectedOrganisation.Organisation.id, + userId: confirmDialog.memberUserId, + onSuccess: () => { + setConfirmDialog({ open: false, memberUserId: 0, memberName: "" }); + void refetchMembers(); + }, + onError: (error) => { + console.error(error); + }, + }); + } catch (err) { + console.error(err); + } + }; + + // useEffect(() => { + // if (!open) return; + // void refetchOrganisations(); + // }, [open, refetchOrganisations]); + + useEffect(() => { + if (!open) return; + void refetchMembers(); + }, [open, refetchMembers]); + + return ( + + + {trigger || ( + + )} + + + + + Organisations + + +
+
+ { + setSelectedOrganisation(org); + localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`); + }} + onCreateOrganisation={async (organisationId) => { + await refetchOrganisations({ selectOrganisationId: organisationId }); + }} + contentClass={ + "data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25" + } + /> +
+ + {selectedOrganisation ? ( +
+
+

+ {selectedOrganisation.Organisation.name} +

+
+

+ Slug: {selectedOrganisation.Organisation.slug} +

+

+ Role: {selectedOrganisation.OrganisationMember.role} +

+ {selectedOrganisation.Organisation.description ? ( +

+ {selectedOrganisation.Organisation.description} +

+ ) : ( +

+ No description +

+ )} +
+
+
+

+ {members.length} Member{members.length !== 1 ? "s" : ""} +

+
+
+ {members.map((member) => ( +
+
+ + + {member.OrganisationMember.role} + +
+ {(selectedOrganisation.OrganisationMember.role === "owner" || + selectedOrganisation.OrganisationMember.role === + "admin") && + member.OrganisationMember.role !== "owner" && + member.User.id !== user.id && ( + + )} +
+ ))} +
+ {(selectedOrganisation.OrganisationMember.role === "owner" || + selectedOrganisation.OrganisationMember.role === "admin") && ( + m.User.username)} + onSuccess={refetchMembers} + trigger={ + + } + /> + )} +
+
+
+ ) : ( +

No organisations yet.

+ )} + + setConfirmDialog({ ...confirmDialog, open })} + onConfirm={confirmRemoveMember} + title="Remove Member" + processingText="Removing..." + message={`Are you sure you want to remove ${confirmDialog.memberName} from this organisation?`} + confirmText="Remove" + variant="destructive" + /> +
+
+
+ ); +} + +export default OrganisationsDialog; diff --git a/packages/frontend/src/components/ui/dropdown-menu.tsx b/packages/frontend/src/components/ui/dropdown-menu.tsx index eaff2c7..a74240f 100644 --- a/packages/frontend/src/components/ui/dropdown-menu.tsx +++ b/packages/frontend/src/components/ui/dropdown-menu.tsx @@ -1,7 +1,6 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import type * as React from "react"; - import { cn } from "@/lib/utils"; function DropdownMenu({ ...props }: React.ComponentProps) { diff --git a/packages/frontend/src/components/ui/field.tsx b/packages/frontend/src/components/ui/field.tsx index 5dbd84c..0913826 100644 --- a/packages/frontend/src/components/ui/field.tsx +++ b/packages/frontend/src/components/ui/field.tsx @@ -11,6 +11,7 @@ export function Field({ submitAttempted, placeholder, error, + tabIndex, }: { label: string; value?: string; @@ -20,12 +21,13 @@ export function Field({ submitAttempted?: boolean; placeholder?: string; error?: string; + tabIndex?: number; }) { const [internalTouched, setInternalTouched] = useState(false); const isTouched = submitAttempted || internalTouched; const invalidMessage = useMemo(() => { - if (!isTouched) { + if (!isTouched && value === "") { return ""; } return validate?.(value) ?? ""; @@ -42,11 +44,15 @@ export function Field({ id={label} placeholder={placeholder ?? label} value={value} - onChange={onChange} + onChange={(e) => { + onChange(e); + setInternalTouched(true); + }} onBlur={() => setInternalTouched(true)} name={label} aria-invalid={error !== undefined || invalidMessage !== ""} type={hidden ? "password" : "text"} + tabIndex={tabIndex} />
{error || invalidMessage !== "" ? (