converted account and organisation pages to dialogs

This commit is contained in:
Oliver Bryan
2026-01-03 12:42:39 +00:00
parent 33da8bde85
commit 5160a6a554
11 changed files with 441 additions and 431 deletions

View File

@@ -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<string | null>(null);
const [error, setError] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false);
const [userId, setUserId] = useState<number | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="ghost" className="flex w-full justify-end px-2 py-1 m-0 h-auto">
My Account
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Account</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<UploadAvatar
name={name}
username={username}
avatarURL={avatarURL}
onAvatarUploaded={setAvatarUrl}
/>
{avatarURL && (
<Button
variant={"dummy"}
type={"button"}
onClick={() => {
setAvatarUrl(null);
}}
className="-mt-2 hover:underline"
>
Remove Avatar
</Button>
)}
<Field
label="Full Name"
value={name}
onChange={(e) => setName(e.target.value)}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
submitAttempted={submitAttempted}
/>
<Field
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Leave empty to keep current password"
hidden={true}
/>
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>}
<div className="flex justify-end">
<Button variant={"outline"} type={"submit"} className="px-12">
Save
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export default AccountDialog;

View File

@@ -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"}

View File

@@ -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 (
<div className="flex gap-12 items-center justify-between p-1">
{children}
<div className="flex gap-1 items-center">
<DropdownMenu>
<DropdownMenuTrigger className="text-sm">
<SmallUserDisplay user={user} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"}>
<DropdownMenuItem asChild className="flex items-end justify-end">
<Link to="/" className="p-0 text-end">
Home
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<Link to="/settings/account" className="p-0 text-end">
My Account
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<Link to="/settings/organisations" className="p-0 text-end">
My Organisations
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<ServerConfigurationDialog
trigger={
<Button
variant="ghost"
className="flex w-full gap-2 items-center justify-end text-end px-2 py-1 m-0 h-auto"
title="Server Configuration"
>
Server Configuration
</Button>
}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex items-end justify-end p-0 m-0">
<LogOutButton noStyle className={"flex w-full justify-end"} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@@ -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<void>;
placeholder?: string;
contentClass?: string;
}) {
const [open, setOpen] = useState(false);
@@ -44,7 +46,7 @@ export function OrganisationSelect({
<SelectTrigger className="text-sm" isOpen={open}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent side="bottom" position="popper">
<SelectContent side="bottom" position="popper" className={contentClass}>
<SelectGroup>
<SelectLabel>Organisations</SelectLabel>
{organisations.map((organisation) => (

View File

@@ -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<void>;
}) {
const user = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord;
const [open, setOpen] = useState(false);
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="ghost" className="flex w-full justify-end px-2 py-1 m-0 h-auto">
My Organisations
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md w-full max-w-[calc(100vw-2rem)]">
<DialogHeader>
<DialogTitle>Organisations</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<OrganisationSelect
organisations={organisations}
selectedOrganisation={selectedOrganisation}
onSelectedOrganisationChange={(org) => {
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"
}
/>
</div>
{selectedOrganisation ? (
<div className="flex flex-col gap-2 min-w-0">
<div className="w-full border p-2 min-w-0 overflow-hidden">
<h2 className="text-xl font-600 mb-2 break-all">
{selectedOrganisation.Organisation.name}
</h2>
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground break-all">
Slug: {selectedOrganisation.Organisation.slug}
</p>
<p className="text-sm text-muted-foreground break-all">
Role: {selectedOrganisation.OrganisationMember.role}
</p>
{selectedOrganisation.Organisation.description ? (
<p className="text-sm break-words">
{selectedOrganisation.Organisation.description}
</p>
) : (
<p className="text-sm text-muted-foreground break-words">
No description
</p>
)}
</div>
</div>
<div className="border p-2 min-w-0 overflow-hidden">
<h2 className="text-xl font-600 mb-2">
{members.length} Member{members.length !== 1 ? "s" : ""}
</h2>
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
{members.map((member) => (
<div
key={member.OrganisationMember.id}
className="flex items-center justify-between p-2 border"
>
<div className="flex items-center gap-2">
<SmallUserDisplay user={member.User} />
<span className="text-sm text-muted-foreground">
{member.OrganisationMember.role}
</span>
</div>
{(selectedOrganisation.OrganisationMember.role === "owner" ||
selectedOrganisation.OrganisationMember.role ===
"admin") &&
member.OrganisationMember.role !== "owner" &&
member.User.id !== user.id && (
<Button
variant="dummy"
size="none"
onClick={() =>
handleRemoveMember(
member.User.id,
member.User.name,
)
}
>
<X className="size-5 text-destructive" />
</Button>
)}
</div>
))}
</div>
{(selectedOrganisation.OrganisationMember.role === "owner" ||
selectedOrganisation.OrganisationMember.role === "admin") && (
<AddMemberDialog
organisationId={selectedOrganisation.Organisation.id}
existingMembers={members.map((m) => m.User.username)}
onSuccess={refetchMembers}
trigger={
<Button variant="outline">
Add user <Plus className="size-4" />
</Button>
}
/>
)}
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No organisations yet.</p>
)}
<ConfirmDialog
open={confirmDialog.open}
onOpenChange={(open) => 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"
/>
</div>
</DialogContent>
</Dialog>
);
}
export default OrganisationsDialog;

View File

@@ -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<typeof DropdownMenuPrimitive.Root>) {

View File

@@ -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}
/>
<div className="flex items-end justify-end w-full text-xs mb-0 -mt-1">
{error || invalidMessage !== "" ? (