mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
frontend indentation set to 2
This commit is contained in:
@@ -7,220 +7,220 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-personality: var(--personality);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-personality: var(--personality);
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-weight: 450;
|
||||
--radius: 0.625rem;
|
||||
--personality: #f26d77;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(73.802% 0.00008 271.152);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--font-weight: 450;
|
||||
--radius: 0.625rem;
|
||||
--personality: #f26d77;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(73.802% 0.00008 271.152);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--font-weight: 400;
|
||||
--personality: #f26d77;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.105 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(100% 0.00011 271.152 / 0.22);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--font-weight: 400;
|
||||
--personality: #f26d77;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.105 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(100% 0.00011 271.152 / 0.22);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
::selection {
|
||||
background-color: var(--personality);
|
||||
color: var(--background);
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
::selection {
|
||||
background-color: var(--personality);
|
||||
color: var(--background);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: var(--font-weight, 400);
|
||||
font-style: normal;
|
||||
font-variant-ligatures: none;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: var(--font-weight, 400);
|
||||
font-style: normal;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.font-200 {
|
||||
font-weight: 200;
|
||||
font-weight: 200;
|
||||
}
|
||||
.font-250 {
|
||||
font-weight: 250;
|
||||
font-weight: 250;
|
||||
}
|
||||
.font-300 {
|
||||
font-weight: 300;
|
||||
font-weight: 300;
|
||||
}
|
||||
.font-350 {
|
||||
font-weight: 350;
|
||||
font-weight: 350;
|
||||
}
|
||||
.font-400 {
|
||||
font-weight: 400;
|
||||
font-weight: 400;
|
||||
}
|
||||
.font-450 {
|
||||
font-weight: 450;
|
||||
font-weight: 450;
|
||||
}
|
||||
.font-500 {
|
||||
font-weight: 500;
|
||||
font-weight: 500;
|
||||
}
|
||||
.font-550 {
|
||||
font-weight: 550;
|
||||
font-weight: 550;
|
||||
}
|
||||
.font-600 {
|
||||
font-weight: 600;
|
||||
font-weight: 600;
|
||||
}
|
||||
.font-650 {
|
||||
font-weight: 650;
|
||||
font-weight: 650;
|
||||
}
|
||||
.font-700 {
|
||||
font-weight: 700;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.noselect {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* react-colorful */
|
||||
.react-colorful {
|
||||
width: 150px !important;
|
||||
height: 150px !important;
|
||||
width: 150px !important;
|
||||
height: 150px !important;
|
||||
}
|
||||
|
||||
.react-colorful__saturation {
|
||||
border-radius: 0 !important;
|
||||
/* cursor: pointer; */
|
||||
border-radius: 0 !important;
|
||||
/* cursor: pointer; */
|
||||
}
|
||||
|
||||
.react-colorful__last-control {
|
||||
border-radius: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.react-colorful__saturation-pointer {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
border: 1px solid white !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
border: 1px solid white !important;
|
||||
}
|
||||
|
||||
.react-colorful__hue {
|
||||
height: 10px !important;
|
||||
cursor: pointer;
|
||||
height: 10px !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.react-colorful__hue-pointer {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
border: 1px solid white !important;
|
||||
width: 16px !important;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -16,163 +16,160 @@ import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
const { user: currentUser, setUser } = useAuthenticatedSession();
|
||||
const updateUser = useUpdateUser();
|
||||
const { user: currentUser, setUser } = useAuthenticatedSession();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
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 [iconPreference, setIconPreference] = useState<IconStyle>("lucide");
|
||||
const [error, setError] = useState("");
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
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 [iconPreference, setIconPreference] = useState<IconStyle>("lucide");
|
||||
const [error, setError] = useState("");
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setName(currentUser.name);
|
||||
setUsername(currentUser.username);
|
||||
setAvatarUrl(currentUser.avatarURL || null);
|
||||
setIconPreference((currentUser.iconPreference as IconStyle) ?? "lucide");
|
||||
setName(currentUser.name);
|
||||
setUsername(currentUser.username);
|
||||
setAvatarUrl(currentUser.avatarURL || null);
|
||||
setIconPreference((currentUser.iconPreference as IconStyle) ?? "lucide");
|
||||
|
||||
setPassword("");
|
||||
setError("");
|
||||
setSubmitAttempted(false);
|
||||
}, [open, currentUser]);
|
||||
setPassword("");
|
||||
setError("");
|
||||
setSubmitAttempted(false);
|
||||
}, [open, currentUser]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitAttempted(true);
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitAttempted(true);
|
||||
|
||||
if (name.trim() === "") {
|
||||
return;
|
||||
}
|
||||
if (name.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await updateUser.mutateAsync({
|
||||
name: name.trim(),
|
||||
password: password.trim() || undefined,
|
||||
avatarURL,
|
||||
iconPreference,
|
||||
});
|
||||
setError("");
|
||||
setUser(data);
|
||||
setPassword("");
|
||||
setOpen(false);
|
||||
try {
|
||||
const data = await updateUser.mutateAsync({
|
||||
name: name.trim(),
|
||||
password: password.trim() || undefined,
|
||||
avatarURL,
|
||||
iconPreference,
|
||||
});
|
||||
setError("");
|
||||
setUser(data);
|
||||
setPassword("");
|
||||
setOpen(false);
|
||||
|
||||
toast.success("Account updated successfully", {
|
||||
dismissible: false,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
setError(message);
|
||||
toast.success("Account updated successfully", {
|
||||
dismissible: false,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
setError(message);
|
||||
|
||||
toast.error(`Error updating account: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
toast.error(`Error updating account: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
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={cn("sm:max-w-sm", error !== "" && "border border-destructive")}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Account</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("sm:max-w-sm", error !== "" && "border border-destructive")}>
|
||||
<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:text-personality"
|
||||
>
|
||||
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}
|
||||
/>
|
||||
<Label className="text-lg -mt-2">Preferences</Label>
|
||||
<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:text-personality"
|
||||
>
|
||||
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}
|
||||
/>
|
||||
<Label className="text-lg -mt-2">Preferences</Label>
|
||||
|
||||
<div className="flex gap-8 justify w-full">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Label className="text-sm">Light/Dark Mode</Label>
|
||||
<ThemeToggle withText />
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Label className="text-sm">Icon Style</Label>
|
||||
<Select
|
||||
value={iconPreference}
|
||||
onValueChange={(v) => setIconPreference(v as IconStyle)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" align="start">
|
||||
<SelectItem value="lucide">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="lucide" size={16} />
|
||||
Lucide
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="pixel">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="pixel" size={16} />
|
||||
Pixel
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="phosphor">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="phosphor" size={16} />
|
||||
Phosphor
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-8 justify w-full">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Label className="text-sm">Light/Dark Mode</Label>
|
||||
<ThemeToggle withText />
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Label className="text-sm">Icon Style</Label>
|
||||
<Select value={iconPreference} onValueChange={(v) => setIconPreference(v as IconStyle)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" align="start">
|
||||
<SelectItem value="lucide">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="lucide" size={16} />
|
||||
Lucide
|
||||
</div>
|
||||
|
||||
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant={"outline"} type={"submit"} className="px-12">
|
||||
Save
|
||||
</Button>
|
||||
</SelectItem>
|
||||
<SelectItem value="pixel">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="pixel" size={16} />
|
||||
Pixel
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
</SelectItem>
|
||||
<SelectItem value="phosphor">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="sun" iconStyle="phosphor" size={16} />
|
||||
Phosphor
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant={"outline"} type={"submit"} className="px-12">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default Account;
|
||||
|
||||
@@ -3,124 +3,121 @@ import { type FormEvent, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field } from "@/components/ui/field";
|
||||
import { useAddOrganisationMember } from "@/lib/query/hooks";
|
||||
import { parseError, user } from "@/lib/server";
|
||||
|
||||
export function AddMember({
|
||||
organisationId,
|
||||
existingMembers,
|
||||
trigger,
|
||||
onSuccess,
|
||||
organisationId,
|
||||
existingMembers,
|
||||
trigger,
|
||||
onSuccess,
|
||||
}: {
|
||||
organisationId: number;
|
||||
existingMembers: string[];
|
||||
trigger?: React.ReactNode;
|
||||
onSuccess?: (user: UserRecord) => void | Promise<void>;
|
||||
organisationId: number;
|
||||
existingMembers: string[];
|
||||
trigger?: React.ReactNode;
|
||||
onSuccess?: (user: UserRecord) => void | Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const addMember = useAddOrganisationMember();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const addMember = useAddOrganisationMember();
|
||||
|
||||
const reset = () => {
|
||||
setUsername("");
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
const reset = () => {
|
||||
setUsername("");
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
|
||||
if (username.trim() === "") {
|
||||
return;
|
||||
}
|
||||
if (username.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingMembers.includes(username)) {
|
||||
setError("user is already a member of this organisation");
|
||||
return;
|
||||
}
|
||||
if (existingMembers.includes(username)) {
|
||||
setError("user is already a member of this organisation");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const userData: UserRecord = await user.byUsername(username);
|
||||
const userId = userData.id;
|
||||
await addMember.mutateAsync({ organisationId, userId, role: "member" });
|
||||
setOpen(false);
|
||||
reset();
|
||||
try {
|
||||
await onSuccess?.(userData);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
console.error(err);
|
||||
setError(message || "failed to add member");
|
||||
setSubmitting(false);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const userData: UserRecord = await user.byUsername(username);
|
||||
const userId = userData.id;
|
||||
await addMember.mutateAsync({ organisationId, userId, role: "member" });
|
||||
setOpen(false);
|
||||
reset();
|
||||
try {
|
||||
await onSuccess?.(userData);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
console.error(err);
|
||||
setError(message || "failed to add member");
|
||||
setSubmitting(false);
|
||||
|
||||
toast.error(`Error adding member: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
toast.error(`Error adding member: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>{trigger || <Button variant="outline">Add Member</Button>}</DialogTrigger>
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>{trigger || <Button variant="outline">Add Member</Button>}</DialogTrigger>
|
||||
|
||||
<DialogContent className={"w-md"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent className={"w-md"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid mt-2">
|
||||
<Field
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Enter username"
|
||||
error={error || undefined}
|
||||
/>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid mt-2">
|
||||
<Field
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Enter username"
|
||||
error={error || undefined}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || (username.trim() === "" && submitAttempted)}
|
||||
>
|
||||
{submitting ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={submitting || (username.trim() === "" && submitAttempted)}>
|
||||
{submitting ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,94 +3,94 @@ import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const FALLBACK_COLOURS = [
|
||||
"bg-teal-500",
|
||||
"bg-rose-500",
|
||||
"bg-indigo-500",
|
||||
"bg-amber-500",
|
||||
"bg-cyan-500",
|
||||
"bg-purple-500",
|
||||
"bg-lime-500",
|
||||
"bg-orange-500",
|
||||
"bg-sky-500",
|
||||
"bg-fuchsia-500",
|
||||
"bg-green-500",
|
||||
"bg-red-500",
|
||||
"bg-violet-500",
|
||||
"bg-yellow-500",
|
||||
"bg-blue-500",
|
||||
"bg-emerald-500",
|
||||
"bg-pink-500",
|
||||
"bg-teal-500",
|
||||
"bg-rose-500",
|
||||
"bg-indigo-500",
|
||||
"bg-amber-500",
|
||||
"bg-cyan-500",
|
||||
"bg-purple-500",
|
||||
"bg-lime-500",
|
||||
"bg-orange-500",
|
||||
"bg-sky-500",
|
||||
"bg-fuchsia-500",
|
||||
"bg-green-500",
|
||||
"bg-red-500",
|
||||
"bg-violet-500",
|
||||
"bg-yellow-500",
|
||||
"bg-blue-500",
|
||||
"bg-emerald-500",
|
||||
"bg-pink-500",
|
||||
];
|
||||
|
||||
function hashStringToIndex(value: string, modulo: number) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return modulo === 0 ? 0 : hash % modulo;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return modulo === 0 ? 0 : hash % modulo;
|
||||
}
|
||||
|
||||
function getInitials(username: string) {
|
||||
username = username.trim();
|
||||
username = username.trim();
|
||||
|
||||
const parts = username.split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
||||
if (parts.length === 0) return username.slice(0, 2).toUpperCase();
|
||||
const parts = username.split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
||||
if (parts.length === 0) return username.slice(0, 2).toUpperCase();
|
||||
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
}
|
||||
|
||||
export default function Avatar({
|
||||
avatarURL: _avatarURL,
|
||||
name: _name,
|
||||
username,
|
||||
size,
|
||||
textClass = "text-xs",
|
||||
strong = false,
|
||||
className,
|
||||
avatarURL: _avatarURL,
|
||||
name: _name,
|
||||
username,
|
||||
size,
|
||||
textClass = "text-xs",
|
||||
strong = false,
|
||||
className,
|
||||
}: {
|
||||
avatarURL?: string | null;
|
||||
name?: string;
|
||||
username?: string;
|
||||
size?: number;
|
||||
textClass?: string;
|
||||
strong?: boolean;
|
||||
className?: string;
|
||||
avatarURL?: string | null;
|
||||
name?: string;
|
||||
username?: string;
|
||||
size?: number;
|
||||
textClass?: string;
|
||||
strong?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
// if the username matches the authed user, use their avatarURL and name (avoid stale data)
|
||||
const { user } = useSession();
|
||||
const avatarURL = !strong && username && user && username === user.username ? user.avatarURL : _avatarURL;
|
||||
const name = !strong && username && user && username === user.username ? user.name : _name;
|
||||
// if the username matches the authed user, use their avatarURL and name (avoid stale data)
|
||||
const { user } = useSession();
|
||||
const avatarURL = !strong && username && user && username === user.username ? user.avatarURL : _avatarURL;
|
||||
const name = !strong && username && user && username === user.username ? user.name : _name;
|
||||
|
||||
const backgroundClass = username
|
||||
? FALLBACK_COLOURS[hashStringToIndex(username, FALLBACK_COLOURS.length)]
|
||||
: "bg-muted";
|
||||
const backgroundClass = username
|
||||
? FALLBACK_COLOURS[hashStringToIndex(username, FALLBACK_COLOURS.length)]
|
||||
: "bg-muted";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full",
|
||||
"text-white font-medium select-none",
|
||||
name && "border",
|
||||
!avatarURL && backgroundClass,
|
||||
"transition-colors",
|
||||
`w-${size || 6}`,
|
||||
`h-${size || 6}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{avatarURL ? (
|
||||
<img
|
||||
src={avatarURL}
|
||||
alt="Avatar"
|
||||
className={`rounded-full object-cover w-${size || 6} h-${size || 6}`}
|
||||
/>
|
||||
) : name ? (
|
||||
<span className={textClass}>{getInitials(name)}</span>
|
||||
) : (
|
||||
<Icon icon="userRound" className={"size-10"} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full",
|
||||
"text-white font-medium select-none",
|
||||
name && "border",
|
||||
!avatarURL && backgroundClass,
|
||||
"transition-colors",
|
||||
`w-${size || 6}`,
|
||||
`h-${size || 6}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{avatarURL ? (
|
||||
<img
|
||||
src={avatarURL}
|
||||
alt="Avatar"
|
||||
className={`rounded-full object-cover w-${size || 6} h-${size || 6}`}
|
||||
/>
|
||||
) : name ? (
|
||||
<span className={textClass}>{getInitials(name)}</span>
|
||||
) : (
|
||||
<Icon icon="userRound" className={"size-10"} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,37 +2,37 @@ import { useMemo } from "react";
|
||||
import { IssueDetails } from "@/components/issue-details";
|
||||
import { useSelection } from "@/components/selection-provider";
|
||||
import {
|
||||
useOrganisationMembers,
|
||||
useSelectedIssue,
|
||||
useSelectedOrganisation,
|
||||
useSelectedProject,
|
||||
useSprints,
|
||||
useOrganisationMembers,
|
||||
useSelectedIssue,
|
||||
useSelectedOrganisation,
|
||||
useSelectedProject,
|
||||
useSprints,
|
||||
} from "@/lib/query/hooks";
|
||||
|
||||
export function IssueDetailPane() {
|
||||
const { selectIssue } = useSelection();
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const issueData = useSelectedIssue();
|
||||
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
||||
const { selectIssue } = useSelection();
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const issueData = useSelectedIssue();
|
||||
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
||||
|
||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
|
||||
if (!issueData || !selectedProject || !selectedOrganisation) {
|
||||
return null;
|
||||
}
|
||||
if (!issueData || !selectedProject || !selectedOrganisation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IssueDetails
|
||||
issueData={issueData}
|
||||
projectKey={selectedProject.Project.key}
|
||||
sprints={sprints}
|
||||
members={members}
|
||||
statuses={statuses}
|
||||
onClose={() => selectIssue(null)}
|
||||
onDelete={() => selectIssue(null)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<IssueDetails
|
||||
issueData={issueData}
|
||||
projectKey={selectedProject.Project.key}
|
||||
sprints={sprints}
|
||||
members={members}
|
||||
statuses={statuses}
|
||||
onClose={() => selectIssue(null)}
|
||||
onDelete={() => selectIssue(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,426 +22,418 @@ import { parseError } from "@/lib/server";
|
||||
import { cn, issueID } from "@/lib/utils";
|
||||
|
||||
function assigneesToStringArray(assignees: { id: number }[]): string[] {
|
||||
if (assignees.length === 0) return ["unassigned"];
|
||||
return assignees.map((a) => a.id.toString());
|
||||
if (assignees.length === 0) return ["unassigned"];
|
||||
return assignees.map((a) => a.id.toString());
|
||||
}
|
||||
|
||||
function stringArrayToAssigneeIds(assigneeIds: string[]): number[] {
|
||||
return assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id));
|
||||
return assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id));
|
||||
}
|
||||
|
||||
export function IssueDetails({
|
||||
issueData,
|
||||
projectKey,
|
||||
sprints,
|
||||
members,
|
||||
statuses,
|
||||
onClose,
|
||||
onDelete,
|
||||
showHeader = true,
|
||||
issueData,
|
||||
projectKey,
|
||||
sprints,
|
||||
members,
|
||||
statuses,
|
||||
onClose,
|
||||
onDelete,
|
||||
showHeader = true,
|
||||
}: {
|
||||
issueData: IssueResponse;
|
||||
projectKey: string;
|
||||
sprints: SprintRecord[];
|
||||
members: UserRecord[];
|
||||
statuses: Record<string, string>;
|
||||
onClose: () => void;
|
||||
onDelete?: () => void;
|
||||
showHeader?: boolean;
|
||||
issueData: IssueResponse;
|
||||
projectKey: string;
|
||||
sprints: SprintRecord[];
|
||||
members: UserRecord[];
|
||||
statuses: Record<string, string>;
|
||||
onClose: () => void;
|
||||
onDelete?: () => void;
|
||||
showHeader?: boolean;
|
||||
}) {
|
||||
const { user } = useSession();
|
||||
const updateIssue = useUpdateIssue();
|
||||
const deleteIssue = useDeleteIssue();
|
||||
const { user } = useSession();
|
||||
const updateIssue = useUpdateIssue();
|
||||
const deleteIssue = useDeleteIssue();
|
||||
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
||||
const [sprintId, setSprintId] = useState<string>("unassigned");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<number | null>(null);
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
||||
const [sprintId, setSprintId] = useState<string>("unassigned");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [originalTitle, setOriginalTitle] = useState("");
|
||||
const [isSavingTitle, setIsSavingTitle] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [originalTitle, setOriginalTitle] = useState("");
|
||||
const [isSavingTitle, setIsSavingTitle] = useState(false);
|
||||
|
||||
const [description, setDescription] = useState("");
|
||||
const [originalDescription, setOriginalDescription] = useState("");
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [description, setDescription] = useState("");
|
||||
const [originalDescription, setOriginalDescription] = useState("");
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const isAssignee = assigneeIds.some((id) => user?.id === Number(id));
|
||||
const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned");
|
||||
const hasMultipleAssignees = actualAssigneeIds.length > 1;
|
||||
const isAssignee = assigneeIds.some((id) => user?.id === Number(id));
|
||||
const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned");
|
||||
const hasMultipleAssignees = actualAssigneeIds.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
|
||||
setAssigneeIds(assigneesToStringArray(issueData.Assignees));
|
||||
setStatus(issueData.Issue.status);
|
||||
setTitle(issueData.Issue.title);
|
||||
setOriginalTitle(issueData.Issue.title);
|
||||
setDescription(issueData.Issue.description);
|
||||
setOriginalDescription(issueData.Issue.description);
|
||||
useEffect(() => {
|
||||
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
|
||||
setAssigneeIds(assigneesToStringArray(issueData.Assignees));
|
||||
setStatus(issueData.Issue.status);
|
||||
setTitle(issueData.Issue.title);
|
||||
setOriginalTitle(issueData.Issue.title);
|
||||
setDescription(issueData.Issue.description);
|
||||
setOriginalDescription(issueData.Issue.description);
|
||||
setIsEditingDescription(false);
|
||||
}, [issueData]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) {
|
||||
window.clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSprintChange = async (value: string) => {
|
||||
setSprintId(value);
|
||||
const newSprintId = value === "unassigned" ? null : Number(value);
|
||||
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
sprintId: newSprintId,
|
||||
});
|
||||
toast.success(
|
||||
<>
|
||||
Successfully updated sprint to{" "}
|
||||
{value === "unassigned" ? (
|
||||
"Unassigned"
|
||||
) : (
|
||||
<SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} />
|
||||
)}{" "}
|
||||
for {issueID(projectKey, issueData.Issue.number)}
|
||||
</>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("error updating sprint:", error);
|
||||
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
|
||||
toast.error(
|
||||
<>
|
||||
Error updating sprint to{" "}
|
||||
{value === "unassigned" ? (
|
||||
"Unassigned"
|
||||
) : (
|
||||
<SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} />
|
||||
)}{" "}
|
||||
for {issueID(projectKey, issueData.Issue.number)}
|
||||
</>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssigneeChange = async (newAssigneeIds: string[]) => {
|
||||
const previousAssigneeIds = assigneeIds;
|
||||
setAssigneeIds(newAssigneeIds);
|
||||
|
||||
const newAssigneeIdNumbers = stringArrayToAssigneeIds(newAssigneeIds);
|
||||
const previousAssigneeIdNumbers = stringArrayToAssigneeIds(previousAssigneeIds);
|
||||
|
||||
const hasChanged =
|
||||
newAssigneeIdNumbers.length !== previousAssigneeIdNumbers.length ||
|
||||
!newAssigneeIdNumbers.every((id) => previousAssigneeIdNumbers.includes(id));
|
||||
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
assigneeIds: newAssigneeIdNumbers,
|
||||
});
|
||||
const assignedUsers = members.filter((member) => newAssigneeIdNumbers.includes(member.id));
|
||||
const displayText =
|
||||
assignedUsers.length === 0
|
||||
? "Unassigned"
|
||||
: assignedUsers.length === 1
|
||||
? assignedUsers[0].name
|
||||
: `${assignedUsers.length} assignees`;
|
||||
toast.success(
|
||||
<div className={"flex items-center gap-2"}>
|
||||
Updated assignees to {displayText} for {issueID(projectKey, issueData.Issue.number)}
|
||||
</div>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("error updating assignees:", error);
|
||||
setAssigneeIds(previousAssigneeIds);
|
||||
toast.error(`Error updating assignees: ${parseError(error as Error)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (value: string) => {
|
||||
setStatus(value);
|
||||
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
status: value,
|
||||
});
|
||||
toast.success(
|
||||
<>
|
||||
{issueID(projectKey, issueData.Issue.number)}'s status updated to{" "}
|
||||
<StatusTag status={value} colour={statuses[value]} />
|
||||
</>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("error updating status:", error);
|
||||
setStatus(issueData.Issue.status);
|
||||
toast.error(`Error updating status: ${parseError(error as Error)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
setLinkCopied(true);
|
||||
if (copyTimeoutRef.current) {
|
||||
window.clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
copyTimeoutRef.current = window.setTimeout(() => {
|
||||
setLinkCopied(false);
|
||||
copyTimeoutRef.current = null;
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error("error copying issue link:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleSave = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (trimmedTitle === "" || trimmedTitle === originalTitle) {
|
||||
setTitle(originalTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingTitle(true);
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
title: trimmedTitle,
|
||||
});
|
||||
setOriginalTitle(trimmedTitle);
|
||||
toast.success(`${issueID(projectKey, issueData.Issue.number)} Title updated`);
|
||||
} catch (error) {
|
||||
console.error("error updating title:", error);
|
||||
setTitle(originalTitle);
|
||||
} finally {
|
||||
setIsSavingTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDescriptionSave = async () => {
|
||||
const trimmedDescription = description.trim();
|
||||
if (trimmedDescription === originalDescription) {
|
||||
if (trimmedDescription === "") {
|
||||
setIsEditingDescription(false);
|
||||
}, [issueData]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) {
|
||||
window.clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
setIsSavingDescription(true);
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
description: trimmedDescription,
|
||||
});
|
||||
setOriginalDescription(trimmedDescription);
|
||||
setDescription(trimmedDescription);
|
||||
toast.success(`${issueID(projectKey, issueData.Issue.number)} Description updated`);
|
||||
if (trimmedDescription === "") {
|
||||
setIsEditingDescription(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error updating description:", error);
|
||||
setDescription(originalDescription);
|
||||
} finally {
|
||||
setIsSavingDescription(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSprintChange = async (value: string) => {
|
||||
setSprintId(value);
|
||||
const newSprintId = value === "unassigned" ? null : Number(value);
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
await deleteIssue.mutateAsync(issueData.Issue.id);
|
||||
onDelete?.();
|
||||
toast.success(`Deleted issue ${issueID(projectKey, issueData.Issue.number)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`error deleting issue ${issueID(projectKey, issueData.Issue.number)}`, error);
|
||||
toast.error(
|
||||
`Error deleting issue ${issueID(projectKey, issueData.Issue.number)}: ${parseError(error as Error)}`,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
sprintId: newSprintId,
|
||||
});
|
||||
toast.success(
|
||||
<>
|
||||
Successfully updated sprint to{" "}
|
||||
{value === "unassigned" ? (
|
||||
"Unassigned"
|
||||
) : (
|
||||
<SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} />
|
||||
)}{" "}
|
||||
for {issueID(projectKey, issueData.Issue.number)}
|
||||
</>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("error updating sprint:", error);
|
||||
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
|
||||
toast.error(
|
||||
<>
|
||||
Error updating sprint to{" "}
|
||||
{value === "unassigned" ? (
|
||||
"Unassigned"
|
||||
) : (
|
||||
<SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} />
|
||||
)}{" "}
|
||||
for {issueID(projectKey, issueData.Issue.number)}
|
||||
</>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssigneeChange = async (newAssigneeIds: string[]) => {
|
||||
const previousAssigneeIds = assigneeIds;
|
||||
setAssigneeIds(newAssigneeIds);
|
||||
|
||||
const newAssigneeIdNumbers = stringArrayToAssigneeIds(newAssigneeIds);
|
||||
const previousAssigneeIdNumbers = stringArrayToAssigneeIds(previousAssigneeIds);
|
||||
|
||||
const hasChanged =
|
||||
newAssigneeIdNumbers.length !== previousAssigneeIdNumbers.length ||
|
||||
!newAssigneeIdNumbers.every((id) => previousAssigneeIdNumbers.includes(id));
|
||||
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
assigneeIds: newAssigneeIdNumbers,
|
||||
});
|
||||
const assignedUsers = members.filter((member) => newAssigneeIdNumbers.includes(member.id));
|
||||
const displayText =
|
||||
assignedUsers.length === 0
|
||||
? "Unassigned"
|
||||
: assignedUsers.length === 1
|
||||
? assignedUsers[0].name
|
||||
: `${assignedUsers.length} assignees`;
|
||||
toast.success(
|
||||
<div className={"flex items-center gap-2"}>
|
||||
Updated assignees to {displayText} for {issueID(projectKey, issueData.Issue.number)}
|
||||
</div>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("error updating assignees:", error);
|
||||
setAssigneeIds(previousAssigneeIds);
|
||||
toast.error(`Error updating assignees: ${parseError(error as Error)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (value: string) => {
|
||||
setStatus(value);
|
||||
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
status: value,
|
||||
});
|
||||
toast.success(
|
||||
<>
|
||||
{issueID(projectKey, issueData.Issue.number)}'s status updated to{" "}
|
||||
<StatusTag status={value} colour={statuses[value]} />
|
||||
</>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("error updating status:", error);
|
||||
setStatus(issueData.Issue.status);
|
||||
toast.error(`Error updating status: ${parseError(error as Error)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
setLinkCopied(true);
|
||||
if (copyTimeoutRef.current) {
|
||||
window.clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
copyTimeoutRef.current = window.setTimeout(() => {
|
||||
setLinkCopied(false);
|
||||
copyTimeoutRef.current = null;
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error("error copying issue link:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleSave = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (trimmedTitle === "" || trimmedTitle === originalTitle) {
|
||||
setTitle(originalTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingTitle(true);
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
title: trimmedTitle,
|
||||
});
|
||||
setOriginalTitle(trimmedTitle);
|
||||
toast.success(`${issueID(projectKey, issueData.Issue.number)} Title updated`);
|
||||
} catch (error) {
|
||||
console.error("error updating title:", error);
|
||||
setTitle(originalTitle);
|
||||
} finally {
|
||||
setIsSavingTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDescriptionSave = async () => {
|
||||
const trimmedDescription = description.trim();
|
||||
if (trimmedDescription === originalDescription) {
|
||||
if (trimmedDescription === "") {
|
||||
setIsEditingDescription(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingDescription(true);
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
description: trimmedDescription,
|
||||
});
|
||||
setOriginalDescription(trimmedDescription);
|
||||
setDescription(trimmedDescription);
|
||||
toast.success(`${issueID(projectKey, issueData.Issue.number)} Description updated`);
|
||||
if (trimmedDescription === "") {
|
||||
setIsEditingDescription(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error updating description:", error);
|
||||
setDescription(originalDescription);
|
||||
} finally {
|
||||
setIsSavingDescription(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
await deleteIssue.mutateAsync(issueData.Issue.id);
|
||||
onDelete?.();
|
||||
toast.success(`Deleted issue ${issueID(projectKey, issueData.Issue.number)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`error deleting issue ${issueID(projectKey, issueData.Issue.number)}`, error);
|
||||
toast.error(
|
||||
`Error deleting issue ${issueID(projectKey, issueData.Issue.number)}: ${parseError(
|
||||
error as Error,
|
||||
)}`,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{showHeader && (
|
||||
<div className="flex flex-row items-center justify-end border-b h-[25px]">
|
||||
<span className="w-full">
|
||||
<p className="text-sm w-fit px-1 font-700">
|
||||
{issueID(projectKey, issueData.Issue.number)}
|
||||
</p>
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<IconButton onClick={handleCopyLink} title={linkCopied ? "Copied" : "Copy link"}>
|
||||
{linkCopied ? <Icon icon="check" /> : <Icon icon="link" />}
|
||||
</IconButton>
|
||||
<IconButton variant="destructive" onClick={handleDelete} title={"Delete issue"}>
|
||||
<Icon icon="trash" />
|
||||
</IconButton>
|
||||
<IconButton onClick={onClose} title={"Close"}>
|
||||
<Icon icon="x" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col w-full p-2 py-2 gap-2">
|
||||
<div className="flex gap-2">
|
||||
<StatusSelect
|
||||
statuses={statuses}
|
||||
value={status}
|
||||
onChange={handleStatusChange}
|
||||
trigger={({ isOpen, value }) => (
|
||||
<SelectTrigger
|
||||
className="group w-auto flex items-center"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<StatusTag
|
||||
status={value}
|
||||
colour={statuses[value]}
|
||||
className="hover:opacity-85"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
)}
|
||||
/>
|
||||
<div className="flex w-full items-center min-w-0">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onBlur={handleTitleSave}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
setTitle(originalTitle);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
disabled={isSavingTitle}
|
||||
className={cn(
|
||||
"w-full border-0 border-b-1 border-b-input/50",
|
||||
"hover:border-b-input focus:border-b-input h-auto",
|
||||
)}
|
||||
inputClassName={cn("bg-background px-1.5 font-600")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{description || isEditingDescription ? (
|
||||
<Textarea
|
||||
ref={descriptionRef}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
onBlur={handleDescriptionSave}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" || (event.ctrlKey && event.key === "Enter")) {
|
||||
setDescription(originalDescription);
|
||||
if (originalDescription === "") {
|
||||
setIsEditingDescription(false);
|
||||
}
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Add a description..."
|
||||
disabled={isSavingDescription}
|
||||
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground justify-start px-2"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(true);
|
||||
setTimeout(() => descriptionRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
Add description
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Sprint:</span>
|
||||
<SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-sm pt-2">Assignees:</span>
|
||||
<MultiAssigneeSelect
|
||||
users={members}
|
||||
assigneeIds={assigneeIds}
|
||||
onChange={handleAssigneeChange}
|
||||
fallbackUsers={issueData.Assignees}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Created by:</span>
|
||||
<SmallUserDisplay user={issueData.Creator} className={"text-sm"} />
|
||||
</div>
|
||||
|
||||
{isAssignee && (
|
||||
<div className={cn("flex flex-col gap-2", hasMultipleAssignees && "cursor-not-allowed")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TimerModal issueId={issueData.Issue.id} disabled={hasMultipleAssignees} />
|
||||
<TimerDisplay issueId={issueData.Issue.id} />
|
||||
</div>
|
||||
{hasMultipleAssignees && (
|
||||
<span className="text-xs text-destructive/85 font-600">
|
||||
Timers cannot be used on issues with multiple assignees
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete issue"
|
||||
message="This will permanently delete the issue."
|
||||
processingText="Deleting..."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{showHeader && (
|
||||
<div className="flex flex-row items-center justify-end border-b h-[25px]">
|
||||
<span className="w-full">
|
||||
<p className="text-sm w-fit px-1 font-700">{issueID(projectKey, issueData.Issue.number)}</p>
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<IconButton onClick={handleCopyLink} title={linkCopied ? "Copied" : "Copy link"}>
|
||||
{linkCopied ? <Icon icon="check" /> : <Icon icon="link" />}
|
||||
</IconButton>
|
||||
<IconButton variant="destructive" onClick={handleDelete} title={"Delete issue"}>
|
||||
<Icon icon="trash" />
|
||||
</IconButton>
|
||||
<IconButton onClick={onClose} title={"Close"}>
|
||||
<Icon icon="x" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
|
||||
<div className="flex flex-col w-full p-2 py-2 gap-2">
|
||||
<div className="flex gap-2">
|
||||
<StatusSelect
|
||||
statuses={statuses}
|
||||
value={status}
|
||||
onChange={handleStatusChange}
|
||||
trigger={({ isOpen, value }) => (
|
||||
<SelectTrigger
|
||||
className="group w-auto flex items-center"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<StatusTag status={value} colour={statuses[value]} className="hover:opacity-85" />
|
||||
</SelectTrigger>
|
||||
)}
|
||||
/>
|
||||
<div className="flex w-full items-center min-w-0">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onBlur={handleTitleSave}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
setTitle(originalTitle);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
disabled={isSavingTitle}
|
||||
className={cn(
|
||||
"w-full border-0 border-b-1 border-b-input/50",
|
||||
"hover:border-b-input focus:border-b-input h-auto",
|
||||
)}
|
||||
inputClassName={cn("bg-background px-1.5 font-600")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{description || isEditingDescription ? (
|
||||
<Textarea
|
||||
ref={descriptionRef}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
onBlur={handleDescriptionSave}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" || (event.ctrlKey && event.key === "Enter")) {
|
||||
setDescription(originalDescription);
|
||||
if (originalDescription === "") {
|
||||
setIsEditingDescription(false);
|
||||
}
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Add a description..."
|
||||
disabled={isSavingDescription}
|
||||
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground justify-start px-2"
|
||||
onClick={() => {
|
||||
setIsEditingDescription(true);
|
||||
setTimeout(() => descriptionRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
Add description
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Sprint:</span>
|
||||
<SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-sm pt-2">Assignees:</span>
|
||||
<MultiAssigneeSelect
|
||||
users={members}
|
||||
assigneeIds={assigneeIds}
|
||||
onChange={handleAssigneeChange}
|
||||
fallbackUsers={issueData.Assignees}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Created by:</span>
|
||||
<SmallUserDisplay user={issueData.Creator} className={"text-sm"} />
|
||||
</div>
|
||||
|
||||
{isAssignee && (
|
||||
<div className={cn("flex flex-col gap-2", hasMultipleAssignees && "cursor-not-allowed")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TimerModal issueId={issueData.Issue.id} disabled={hasMultipleAssignees} />
|
||||
<TimerDisplay issueId={issueData.Issue.id} />
|
||||
</div>
|
||||
{hasMultipleAssignees && (
|
||||
<span className="text-xs text-destructive/85 font-600">
|
||||
Timers cannot be used on issues with multiple assignees
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete issue"
|
||||
message="This will permanently delete the issue."
|
||||
processingText="Deleting..."
|
||||
confirmText="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,238 +9,229 @@ import { StatusSelect } from "@/components/status-select";
|
||||
import StatusTag from "@/components/status-tag";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field } from "@/components/ui/field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SelectTrigger } from "@/components/ui/select";
|
||||
import {
|
||||
useCreateIssue,
|
||||
useOrganisationMembers,
|
||||
useSelectedOrganisation,
|
||||
useSelectedProject,
|
||||
useSprints,
|
||||
useCreateIssue,
|
||||
useOrganisationMembers,
|
||||
useSelectedOrganisation,
|
||||
useSelectedProject,
|
||||
useSprints,
|
||||
} from "@/lib/query/hooks";
|
||||
import { parseError } from "@/lib/server";
|
||||
import { cn, issueID } from "@/lib/utils";
|
||||
|
||||
export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
const { user } = useAuthenticatedSession();
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
||||
const createIssue = useCreateIssue();
|
||||
const { user } = useAuthenticatedSession();
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
||||
const createIssue = useCreateIssue();
|
||||
|
||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
const statusOptions = useMemo(() => Object.keys(statuses), [statuses]);
|
||||
const defaultStatus = statusOptions[0] ?? "";
|
||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
const statusOptions = useMemo(() => Object.keys(statuses), [statuses]);
|
||||
const defaultStatus = statusOptions[0] ?? "";
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sprintId, setSprintId] = useState<string>("unassigned");
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
|
||||
const [status, setStatus] = useState<string>(defaultStatus);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sprintId, setSprintId] = useState<string>("unassigned");
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
|
||||
const [status, setStatus] = useState<string>(defaultStatus);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setSprintId("unassigned");
|
||||
setAssigneeIds(["unassigned"]);
|
||||
setStatus(defaultStatus);
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setSprintId("unassigned");
|
||||
setAssigneeIds(["unassigned"]);
|
||||
setStatus(defaultStatus);
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
|
||||
if (
|
||||
title.trim() === "" ||
|
||||
description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH ||
|
||||
title.trim().length > ISSUE_TITLE_MAX_LENGTH
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
title.trim() === "" ||
|
||||
description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH ||
|
||||
title.trim().length > ISSUE_TITLE_MAX_LENGTH
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.id) {
|
||||
setError("you must be logged in to create an issue");
|
||||
return;
|
||||
}
|
||||
if (!user.id) {
|
||||
setError("you must be logged in to create an issue");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
setError("select a project first");
|
||||
return;
|
||||
}
|
||||
if (!selectedProject) {
|
||||
setError("select a project first");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const data = await createIssue.mutateAsync({
|
||||
projectId: selectedProject.Project.id,
|
||||
title,
|
||||
description,
|
||||
sprintId: sprintId === "unassigned" ? null : Number(sprintId),
|
||||
assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)),
|
||||
status: status.trim() === "" ? undefined : status,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success(`Created ${issueID(selectedProject.Project.key, data.number)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
setError(message);
|
||||
setSubmitting(false);
|
||||
toast.error(`Error creating issue: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
try {
|
||||
const data = await createIssue.mutateAsync({
|
||||
projectId: selectedProject.Project.id,
|
||||
title,
|
||||
description,
|
||||
sprintId: sprintId === "unassigned" ? null : Number(sprintId),
|
||||
assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)),
|
||||
status: status.trim() === "" ? undefined : status,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success(`Created ${issueID(selectedProject.Project.key, data.number)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
setError(message);
|
||||
setSubmitting(false);
|
||||
toast.error(`Error creating issue: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" disabled={!selectedProject}>
|
||||
Create Issue
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" disabled={!selectedProject}>
|
||||
Create Issue
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className={cn("w-md", error && "border-destructive")}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("w-md", error && "border-destructive")}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid">
|
||||
{statusOptions.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Label>Status</Label>
|
||||
<StatusSelect
|
||||
statuses={statuses}
|
||||
value={status}
|
||||
onChange={(newValue) => {
|
||||
if (newValue.trim() === "") return;
|
||||
setStatus(newValue);
|
||||
}}
|
||||
trigger={({ isOpen, value }) => (
|
||||
<SelectTrigger
|
||||
className="group flex items-center w-min"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<StatusTag
|
||||
status={value}
|
||||
colour={statuses[value]}
|
||||
className="hover:opacity-85"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid">
|
||||
{statusOptions.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Label>Status</Label>
|
||||
<StatusSelect
|
||||
statuses={statuses}
|
||||
value={status}
|
||||
onChange={(newValue) => {
|
||||
if (newValue.trim() === "") return;
|
||||
setStatus(newValue);
|
||||
}}
|
||||
trigger={({ isOpen, value }) => (
|
||||
<SelectTrigger
|
||||
className="group flex items-center w-min"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<StatusTag status={value} colour={statuses[value]} className="hover:opacity-85" />
|
||||
</SelectTrigger>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
validate={(value) =>
|
||||
value.trim() === ""
|
||||
? "Cannot be empty"
|
||||
: value.trim().length > ISSUE_TITLE_MAX_LENGTH
|
||||
? `Too long (${ISSUE_TITLE_MAX_LENGTH} character limit)`
|
||||
: undefined
|
||||
}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Demo Issue"
|
||||
maxLength={ISSUE_TITLE_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Description (optional)"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
validate={(value) =>
|
||||
value.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH
|
||||
? `Too long (${ISSUE_DESCRIPTION_MAX_LENGTH} character limit)`
|
||||
: undefined
|
||||
}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Optional details"
|
||||
maxLength={ISSUE_DESCRIPTION_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
validate={(value) =>
|
||||
value.trim() === ""
|
||||
? "Cannot be empty"
|
||||
: value.trim().length > ISSUE_TITLE_MAX_LENGTH
|
||||
? `Too long (${ISSUE_TITLE_MAX_LENGTH} character limit)`
|
||||
: undefined
|
||||
}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Demo Issue"
|
||||
maxLength={ISSUE_TITLE_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Description (optional)"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
validate={(value) =>
|
||||
value.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH
|
||||
? `Too long (${ISSUE_DESCRIPTION_MAX_LENGTH} character limit)`
|
||||
: undefined
|
||||
}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Optional details"
|
||||
maxLength={ISSUE_DESCRIPTION_MAX_LENGTH}
|
||||
/>
|
||||
|
||||
{sprints.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-0">
|
||||
<Label className="text-sm">Sprint</Label>
|
||||
<SprintSelect sprints={sprints} value={sprintId} onChange={setSprintId} />
|
||||
</div>
|
||||
)}
|
||||
{sprints.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-0">
|
||||
<Label className="text-sm">Sprint</Label>
|
||||
<SprintSelect sprints={sprints} value={sprintId} onChange={setSprintId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{members.length > 0 && (
|
||||
<div className="flex items-start gap-2 mt-4">
|
||||
<Label className="text-sm pt-2">Assignees</Label>
|
||||
<MultiAssigneeSelect
|
||||
users={members}
|
||||
assigneeIds={assigneeIds}
|
||||
onChange={setAssigneeIds}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{members.length > 0 && (
|
||||
<div className="flex items-start gap-2 mt-4">
|
||||
<Label className="text-sm pt-2">Assignees</Label>
|
||||
<MultiAssigneeSelect users={members} assigneeIds={assigneeIds} onChange={setAssigneeIds} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||
{error ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||
{error ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) &&
|
||||
submitAttempted) ||
|
||||
(description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH &&
|
||||
submitAttempted)
|
||||
}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) &&
|
||||
submitAttempted) ||
|
||||
(description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && submitAttempted)
|
||||
}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,53 +3,53 @@ import { type ReactNode, useMemo } from "react";
|
||||
import { IssueDetails } from "@/components/issue-details";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
useOrganisationMembers,
|
||||
useSelectedOrganisation,
|
||||
useSelectedProject,
|
||||
useSprints,
|
||||
useOrganisationMembers,
|
||||
useSelectedOrganisation,
|
||||
useSelectedProject,
|
||||
useSprints,
|
||||
} from "@/lib/query/hooks";
|
||||
import { issueID } from "@/lib/utils";
|
||||
|
||||
export function IssueModal({
|
||||
issueData,
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
issueData,
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
}: {
|
||||
issueData: IssueResponse | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
trigger: ReactNode;
|
||||
issueData: IssueResponse | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
trigger: ReactNode;
|
||||
}) {
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
|
||||
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
|
||||
|
||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
|
||||
if (!issueData || !selectedProject || !selectedOrganisation) {
|
||||
return null;
|
||||
}
|
||||
if (!issueData || !selectedProject || !selectedOrganisation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="w-lg p-0" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">
|
||||
{issueID(selectedProject.Project.key, issueData.Issue.number)}
|
||||
</DialogTitle>
|
||||
<IssueDetails
|
||||
issueData={issueData}
|
||||
projectKey={selectedProject.Project.key}
|
||||
sprints={sprints}
|
||||
members={members}
|
||||
statuses={statuses}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onDelete={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="w-lg p-0" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">
|
||||
{issueID(selectedProject.Project.key, issueData.Issue.number)}
|
||||
</DialogTitle>
|
||||
<IssueDetails
|
||||
issueData={issueData}
|
||||
projectKey={selectedProject.Project.key}
|
||||
sprints={sprints}
|
||||
members={members}
|
||||
statuses={statuses}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onDelete={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,71 +7,67 @@ import { parseError } from "@/lib/server";
|
||||
import { cn, formatTime } from "@/lib/utils";
|
||||
|
||||
export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data: TimerState) => void }) {
|
||||
const { data: timerState, error } = useTimerState(issueId);
|
||||
const toggleTimer = useToggleTimer();
|
||||
const endTimer = useEndTimer();
|
||||
const [tick, setTick] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const { data: timerState, error } = useTimerState(issueId);
|
||||
const toggleTimer = useToggleTimer();
|
||||
const endTimer = useEndTimer();
|
||||
const [tick, setTick] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timerState?.isRunning) return;
|
||||
useEffect(() => {
|
||||
if (!timerState?.isRunning) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTick((t) => t + 1);
|
||||
}, 1000);
|
||||
const interval = setInterval(() => {
|
||||
setTick((t) => t + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [timerState?.isRunning]);
|
||||
return () => clearInterval(interval);
|
||||
}, [timerState?.isRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
setErrorMessage(parseError(error as Error));
|
||||
}, [error]);
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
setErrorMessage(parseError(error as Error));
|
||||
}, [error]);
|
||||
|
||||
void tick;
|
||||
const displayTime = getWorkTimeMs(timerState?.timestamps);
|
||||
void tick;
|
||||
const displayTime = getWorkTimeMs(timerState?.timestamps);
|
||||
|
||||
const handleToggle = async () => {
|
||||
try {
|
||||
await toggleTimer.mutateAsync({ issueId });
|
||||
setErrorMessage(null);
|
||||
} catch (err) {
|
||||
setErrorMessage(parseError(err as Error));
|
||||
}
|
||||
};
|
||||
const handleToggle = async () => {
|
||||
try {
|
||||
await toggleTimer.mutateAsync({ issueId });
|
||||
setErrorMessage(null);
|
||||
} catch (err) {
|
||||
setErrorMessage(parseError(err as Error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = async () => {
|
||||
try {
|
||||
const data = await endTimer.mutateAsync({ issueId });
|
||||
if (data) {
|
||||
onEnd?.(data);
|
||||
}
|
||||
setErrorMessage(null);
|
||||
} catch (err) {
|
||||
setErrorMessage(parseError(err as Error));
|
||||
}
|
||||
};
|
||||
const handleEnd = async () => {
|
||||
try {
|
||||
const data = await endTimer.mutateAsync({ issueId });
|
||||
if (data) {
|
||||
onEnd?.(data);
|
||||
}
|
||||
setErrorMessage(null);
|
||||
} catch (err) {
|
||||
setErrorMessage(parseError(err as Error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className={cn("text-6xl", !timerState?.isRunning && "text-muted-foreground")}>
|
||||
{formatTime(displayTime)}
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className={cn("text-6xl", !timerState?.isRunning && "text-muted-foreground")}>
|
||||
{formatTime(displayTime)}
|
||||
</div>
|
||||
|
||||
{errorMessage && <p className="text-red-500 text-sm">{errorMessage}</p>}
|
||||
{errorMessage && <p className="text-red-500 text-sm">{errorMessage}</p>}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button onClick={handleToggle}>
|
||||
{!timerState ? "Start" : timerState.isRunning ? "Pause" : "Resume"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEnd}
|
||||
variant="outline"
|
||||
disabled={!timerState || timerState.endedAt != null}
|
||||
>
|
||||
End
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="flex gap-4">
|
||||
<Button onClick={handleToggle}>
|
||||
{!timerState ? "Start" : timerState.isRunning ? "Pause" : "Resume"}
|
||||
</Button>
|
||||
<Button onClick={handleEnd} variant="outline" disabled={!timerState || timerState.endedAt != null}>
|
||||
End
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,138 +7,135 @@ import { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/qu
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function IssuesTable({
|
||||
columns = {},
|
||||
className,
|
||||
columns = {},
|
||||
className,
|
||||
}: {
|
||||
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
|
||||
className: string;
|
||||
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
|
||||
className: string;
|
||||
}) {
|
||||
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
|
||||
const { data: issuesData = [] } = useIssues(selectedProjectId);
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
|
||||
const { data: issuesData = [] } = useIssues(selectedProjectId);
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
|
||||
const issues = useMemo(() => [...issuesData].reverse(), [issuesData]);
|
||||
const issues = useMemo(() => [...issuesData].reverse(), [issuesData]);
|
||||
|
||||
const getIssueUrl = (issueNumber: number) => {
|
||||
if (!selectedOrganisation || !selectedProject) return "#";
|
||||
const params = new URLSearchParams();
|
||||
params.set("o", selectedOrganisation.Organisation.slug.toLowerCase());
|
||||
params.set("p", selectedProject.Project.key.toLowerCase());
|
||||
params.set("i", issueNumber.toString());
|
||||
return `/app?${params.toString()}`;
|
||||
};
|
||||
const getIssueUrl = (issueNumber: number) => {
|
||||
if (!selectedOrganisation || !selectedProject) return "#";
|
||||
const params = new URLSearchParams();
|
||||
params.set("o", selectedOrganisation.Organisation.slug.toLowerCase());
|
||||
params.set("p", selectedProject.Project.key.toLowerCase());
|
||||
params.set("i", issueNumber.toString());
|
||||
return `/app?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleLinkClick = (e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
const handleLinkClick = (e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<Table className={cn("table-fixed", className)}>
|
||||
<TableHeader>
|
||||
<TableRow hoverEffect={false} className="bg-muted/20">
|
||||
{(columns.id == null || columns.id === true) && (
|
||||
<TableHead className="text-right w-10 border-r">ID</TableHead>
|
||||
)}
|
||||
{(columns.title == null || columns.title === true) && <TableHead>Title</TableHead>}
|
||||
{(columns.description == null || columns.description === true) && (
|
||||
<TableHead>Description</TableHead>
|
||||
)}
|
||||
{/* below is kept blank to fill the space, used as the "Assignee" column */}
|
||||
{(columns.assignee == null || columns.assignee === true) && (
|
||||
<TableHead className="w-[1%]"></TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{issues.map((issueData) => (
|
||||
<TableRow
|
||||
key={issueData.Issue.id}
|
||||
className="cursor-pointer max-w-full"
|
||||
onClick={() => {
|
||||
if (issueData.Issue.id === selectedIssueId) {
|
||||
selectIssue(null);
|
||||
return;
|
||||
}
|
||||
selectIssue(issueData);
|
||||
}}
|
||||
>
|
||||
{(columns.id == null || columns.id === true) && (
|
||||
<TableCell className="font-medium border-r text-right p-0">
|
||||
<a
|
||||
href={getIssueUrl(issueData.Issue.number)}
|
||||
onClick={handleLinkClick}
|
||||
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
||||
>
|
||||
{issueData.Issue.number.toString().padStart(3, "0")}
|
||||
</a>
|
||||
</TableCell>
|
||||
)}
|
||||
{(columns.title == null || columns.title === true) && (
|
||||
<TableCell className="min-w-0 p-0">
|
||||
<a
|
||||
href={getIssueUrl(issueData.Issue.number)}
|
||||
onClick={handleLinkClick}
|
||||
className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
||||
>
|
||||
{(columns.status == null || columns.status === true) && (
|
||||
<StatusTag
|
||||
status={issueData.Issue.status}
|
||||
colour={statuses[issueData.Issue.status]}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{issueData.Issue.title}</span>
|
||||
</a>
|
||||
</TableCell>
|
||||
)}
|
||||
{(columns.description == null || columns.description === true) && (
|
||||
<TableCell className="overflow-hidden p-0">
|
||||
<a
|
||||
href={getIssueUrl(issueData.Issue.number)}
|
||||
onClick={handleLinkClick}
|
||||
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
||||
>
|
||||
{issueData.Issue.description}
|
||||
</a>
|
||||
</TableCell>
|
||||
)}
|
||||
{(columns.assignee == null || columns.assignee === true) && (
|
||||
<TableCell className="h-[32px] p-0">
|
||||
<a
|
||||
href={getIssueUrl(issueData.Issue.number)}
|
||||
onClick={handleLinkClick}
|
||||
className="flex items-center justify-end w-full h-full px-2"
|
||||
>
|
||||
{issueData.Assignees && issueData.Assignees.length > 0 && (
|
||||
<div className="flex items-center -space-x-2 pr-1.5">
|
||||
{issueData.Assignees.slice(0, 3).map((assignee) => (
|
||||
<Avatar
|
||||
key={assignee.id}
|
||||
name={assignee.name}
|
||||
username={assignee.username}
|
||||
avatarURL={assignee.avatarURL}
|
||||
textClass="text-xs"
|
||||
className="ring-1 ring-background"
|
||||
/>
|
||||
))}
|
||||
{issueData.Assignees.length > 3 && (
|
||||
<span className="flex items-center justify-center w-6 h-6 text-[10px] font-medium bg-muted text-muted-foreground rounded-full ring-1 ring-background">
|
||||
+{issueData.Assignees.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
return (
|
||||
<Table className={cn("table-fixed", className)}>
|
||||
<TableHeader>
|
||||
<TableRow hoverEffect={false} className="bg-muted/20">
|
||||
{(columns.id == null || columns.id === true) && (
|
||||
<TableHead className="text-right w-10 border-r">ID</TableHead>
|
||||
)}
|
||||
{(columns.title == null || columns.title === true) && <TableHead>Title</TableHead>}
|
||||
{(columns.description == null || columns.description === true) && (
|
||||
<TableHead>Description</TableHead>
|
||||
)}
|
||||
{/* below is kept blank to fill the space, used as the "Assignee" column */}
|
||||
{(columns.assignee == null || columns.assignee === true) && (
|
||||
<TableHead className="w-[1%]"></TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{issues.map((issueData) => (
|
||||
<TableRow
|
||||
key={issueData.Issue.id}
|
||||
className="cursor-pointer max-w-full"
|
||||
onClick={() => {
|
||||
if (issueData.Issue.id === selectedIssueId) {
|
||||
selectIssue(null);
|
||||
return;
|
||||
}
|
||||
selectIssue(issueData);
|
||||
}}
|
||||
>
|
||||
{(columns.id == null || columns.id === true) && (
|
||||
<TableCell className="font-medium border-r text-right p-0">
|
||||
<a
|
||||
href={getIssueUrl(issueData.Issue.number)}
|
||||
onClick={handleLinkClick}
|
||||
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
||||
>
|
||||
{issueData.Issue.number.toString().padStart(3, "0")}
|
||||
</a>
|
||||
</TableCell>
|
||||
)}
|
||||
{(columns.title == null || columns.title === true) && (
|
||||
<TableCell className="min-w-0 p-0">
|
||||
<a
|
||||
href={getIssueUrl(issueData.Issue.number)}
|
||||
onClick={handleLinkClick}
|
||||
className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
||||
>
|
||||
{(columns.status == null || columns.status === true) && (
|
||||
<StatusTag status={issueData.Issue.status} colour={statuses[issueData.Issue.status]} />
|
||||
)}
|
||||
<span className="truncate">{issueData.Issue.title}</span>
|
||||
</a>
|
||||
</TableCell>
|
||||
)}
|
||||
{(columns.description == null || columns.description === true) && (
|
||||
<TableCell className="overflow-hidden p-0">
|
||||
<a
|
||||
href={getIssueUrl(issueData.Issue.number)}
|
||||
onClick={handleLinkClick}
|
||||
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
||||
>
|
||||
{issueData.Issue.description}
|
||||
</a>
|
||||
</TableCell>
|
||||
)}
|
||||
{(columns.assignee == null || columns.assignee === true) && (
|
||||
<TableCell className="h-[32px] p-0">
|
||||
<a
|
||||
href={getIssueUrl(issueData.Issue.number)}
|
||||
onClick={handleLinkClick}
|
||||
className="flex items-center justify-end w-full h-full px-2"
|
||||
>
|
||||
{issueData.Assignees && issueData.Assignees.length > 0 && (
|
||||
<div className="flex items-center -space-x-2 pr-1.5">
|
||||
{issueData.Assignees.slice(0, 3).map((assignee) => (
|
||||
<Avatar
|
||||
key={assignee.id}
|
||||
name={assignee.name}
|
||||
username={assignee.username}
|
||||
avatarURL={assignee.avatarURL}
|
||||
textClass="text-xs"
|
||||
className="ring-1 ring-background"
|
||||
/>
|
||||
))}
|
||||
{issueData.Assignees.length > 3 && (
|
||||
<span className="flex items-center justify-center w-6 h-6 text-[10px] font-medium bg-muted text-muted-foreground rounded-full ring-1 ring-background">
|
||||
+{issueData.Assignees.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export default function Loading({ message, children }: { message?: string; children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]">
|
||||
<Spinner className="size-6" />
|
||||
{message && (
|
||||
<span className="text-xs px-2 py-1 border-2 border-input border-dashed">{message}</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]">
|
||||
<Spinner className="size-6" />
|
||||
{message && <span className="text-xs px-2 py-1 border-2 border-input border-dashed">{message}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,40 +4,40 @@ import Icon from "@/components/ui/icon";
|
||||
import { clearAuth, cn, getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
|
||||
export default function LogOutButton({
|
||||
noStyle = false,
|
||||
className,
|
||||
noStyle = false,
|
||||
className,
|
||||
}: {
|
||||
noStyle?: boolean;
|
||||
className?: string;
|
||||
noStyle?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logOut = async () => {
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
const logOut = async () => {
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
|
||||
try {
|
||||
await fetch(`${getServerURL()}/auth/logout`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {}
|
||||
try {
|
||||
await fetch(`${getServerURL()}/auth/logout`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {}
|
||||
|
||||
clearAuth();
|
||||
navigate(0);
|
||||
};
|
||||
clearAuth();
|
||||
navigate(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={logOut}
|
||||
variant={noStyle ? "dummy" : "destructive"}
|
||||
className={cn("flex gap-2 items-center", noStyle && "px-2 py-1 m-0 h-auto", className)}
|
||||
size={noStyle ? "none" : "default"}
|
||||
>
|
||||
Log out
|
||||
<Icon icon="logOut" size={15} />
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
onClick={logOut}
|
||||
variant={noStyle ? "dummy" : "destructive"}
|
||||
className={cn("flex gap-2 items-center", noStyle && "px-2 py-1 m-0 h-auto", className)}
|
||||
size={noStyle ? "none" : "default"}
|
||||
>
|
||||
Log out
|
||||
<Icon icon="logOut" size={15} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,311 +16,305 @@ import { UploadAvatar } from "@/components/upload-avatar";
|
||||
import { capitalise, cn, getServerURL, setCsrfToken } from "@/lib/utils";
|
||||
|
||||
const DEMO_USERS = [
|
||||
{ name: "User 1", username: "u1", password: "a" },
|
||||
{ name: "User 2", username: "u2", password: "a" },
|
||||
{ name: "User 1", username: "u1", password: "a" },
|
||||
{ name: "User 2", username: "u2", password: "a" },
|
||||
];
|
||||
|
||||
export default function LogInForm() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setUser } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setUser } = useSession();
|
||||
|
||||
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
|
||||
const [showWarning, setShowWarning] = useState(() => {
|
||||
return localStorage.getItem("hide-under-construction") !== "true";
|
||||
});
|
||||
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
|
||||
const [showWarning, setShowWarning] = useState(() => {
|
||||
return localStorage.getItem("hide-under-construction") !== "true";
|
||||
});
|
||||
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
|
||||
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 [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 logIn = () => {
|
||||
if (username.trim() === "" || password.trim() === "") {
|
||||
return;
|
||||
const logIn = () => {
|
||||
if (username.trim() === "" || password.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${getServerURL()}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 200) {
|
||||
setError("");
|
||||
const data = await res.json();
|
||||
setCsrfToken(data.csrfToken);
|
||||
setUser(data.user);
|
||||
const next = searchParams.get("next") || "/app";
|
||||
navigate(next, { replace: true });
|
||||
}
|
||||
|
||||
fetch(`${getServerURL()}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 200) {
|
||||
setError("");
|
||||
const data = await res.json();
|
||||
setCsrfToken(data.csrfToken);
|
||||
setUser(data.user);
|
||||
const next = searchParams.get("next") || "/app";
|
||||
navigate(next, { replace: true });
|
||||
}
|
||||
// unauthorized
|
||||
else if (res.status === 401) {
|
||||
setError("Either the username or password is incorrect");
|
||||
} else {
|
||||
setError("An unknown error occured.");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError(`${err.statusText}`);
|
||||
});
|
||||
};
|
||||
|
||||
const register = () => {
|
||||
if (name.trim() === "" || username.trim() === "" || password.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${getServerURL()}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
username,
|
||||
password,
|
||||
avatarURL,
|
||||
}),
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 200) {
|
||||
setError("");
|
||||
const data = await res.json();
|
||||
setCsrfToken(data.csrfToken);
|
||||
setUser(data.user);
|
||||
const next = searchParams.get("next") || "/app";
|
||||
navigate(next, { replace: true });
|
||||
}
|
||||
// bad request (probably a bad user input)
|
||||
else if (res.status === 400) {
|
||||
const data = await res.json();
|
||||
const firstDetail = data.details ? Object.values(data.details).flat().find(Boolean) : "";
|
||||
setError(firstDetail || data.error || "Bad request");
|
||||
} else {
|
||||
setError("An unknown error occured.");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError(`${err.statusText}`);
|
||||
});
|
||||
};
|
||||
|
||||
const focusFirstInput = () => {
|
||||
const firstInput = document.querySelector('input[type="text"]');
|
||||
if (firstInput) {
|
||||
(firstInput as HTMLInputElement).focus();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
focusFirstInput();
|
||||
}, []);
|
||||
|
||||
const resetForm = () => {
|
||||
setError("");
|
||||
setSubmitAttempted(false);
|
||||
setAvatarUrl(null);
|
||||
requestAnimationFrame(() => focusFirstInput());
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitAttempted(true);
|
||||
if (mode === "login") {
|
||||
logIn();
|
||||
// unauthorized
|
||||
else if (res.status === 401) {
|
||||
setError("Either the username or password is incorrect");
|
||||
} else {
|
||||
register();
|
||||
setError("An unknown error occured.");
|
||||
}
|
||||
};
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError(`${err.statusText}`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* under construction warning */}
|
||||
{showWarning && (
|
||||
<div className="relative flex flex-col border p-4 items-center border-border/50 bg-border/10 gap-2 max-w-lg">
|
||||
<IconButton
|
||||
size="md"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => {
|
||||
localStorage.setItem("hide-under-construction", "true");
|
||||
setShowWarning(false);
|
||||
}}
|
||||
const register = () => {
|
||||
if (name.trim() === "" || username.trim() === "" || password.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${getServerURL()}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
username,
|
||||
password,
|
||||
avatarURL,
|
||||
}),
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 200) {
|
||||
setError("");
|
||||
const data = await res.json();
|
||||
setCsrfToken(data.csrfToken);
|
||||
setUser(data.user);
|
||||
const next = searchParams.get("next") || "/app";
|
||||
navigate(next, { replace: true });
|
||||
}
|
||||
// bad request (probably a bad user input)
|
||||
else if (res.status === 400) {
|
||||
const data = await res.json();
|
||||
const firstDetail = data.details ? Object.values(data.details).flat().find(Boolean) : "";
|
||||
setError(firstDetail || data.error || "Bad request");
|
||||
} else {
|
||||
setError("An unknown error occured.");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError(`${err.statusText}`);
|
||||
});
|
||||
};
|
||||
|
||||
const focusFirstInput = () => {
|
||||
const firstInput = document.querySelector('input[type="text"]');
|
||||
if (firstInput) {
|
||||
(firstInput as HTMLInputElement).focus();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
focusFirstInput();
|
||||
}, []);
|
||||
|
||||
const resetForm = () => {
|
||||
setError("");
|
||||
setSubmitAttempted(false);
|
||||
setAvatarUrl(null);
|
||||
requestAnimationFrame(() => focusFirstInput());
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitAttempted(true);
|
||||
if (mode === "login") {
|
||||
logIn();
|
||||
} else {
|
||||
register();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* under construction warning */}
|
||||
{showWarning && (
|
||||
<div className="relative flex flex-col border p-4 items-center border-border/50 bg-border/10 gap-2 max-w-lg">
|
||||
<IconButton
|
||||
size="md"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => {
|
||||
localStorage.setItem("hide-under-construction", "true");
|
||||
setShowWarning(false);
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</IconButton>
|
||||
<Icon icon="alertTriangle" className="w-16 h-16 text-yellow-500" />
|
||||
<div className="text-center text-sm text-muted-foreground font-500">
|
||||
<p>
|
||||
This application is currently under construction. Your data is very likely to be lost at some
|
||||
point.
|
||||
</p>
|
||||
<p className="font-700 underline underline-offset-3 text-foreground/85 decoration-yellow-500 mt-2">
|
||||
It is not recommended for production use.
|
||||
</p>
|
||||
<p className="mt-2">But you're more than welcome to have a look around!</p>
|
||||
<Dialog open={loginDetailsOpen} onOpenChange={setLoginDetailsOpen}>
|
||||
<DialogTrigger className="text-primary hover:text-personality cursor-pointer mt-2">
|
||||
Login Details
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-xs" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">Demo Login Credentials</DialogTitle>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{DEMO_USERS.map((user) => (
|
||||
<button
|
||||
type="button"
|
||||
key={user.username}
|
||||
className="space-y-2 border border-background hover:border-border hover:bg-border/10 cursor-pointer p-2 text-left"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setUsername(user.username);
|
||||
setPassword(user.password);
|
||||
setLoginDetailsOpen(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</IconButton>
|
||||
<Icon icon="alertTriangle" className="w-16 h-16 text-yellow-500" />
|
||||
<div className="text-center text-sm text-muted-foreground font-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={user.name} username={user.username} />
|
||||
<span className="font-semibold">{user.name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
This application is currently under construction. Your data is very likely to be
|
||||
lost at some point.
|
||||
<span className="font-medium text-foreground">Username:</span> {user.username}
|
||||
</p>
|
||||
<p className="font-700 underline underline-offset-3 text-foreground/85 decoration-yellow-500 mt-2">
|
||||
It is not recommended for production use.
|
||||
<p>
|
||||
<span className="font-medium text-foreground">Password:</span> {user.password}
|
||||
</p>
|
||||
<p className="mt-2">But you're more than welcome to have a look around!</p>
|
||||
<Dialog open={loginDetailsOpen} onOpenChange={setLoginDetailsOpen}>
|
||||
<DialogTrigger className="text-primary hover:text-personality cursor-pointer mt-2">
|
||||
Login Details
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-xs" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">Demo Login Credentials</DialogTitle>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{DEMO_USERS.map((user) => (
|
||||
<button
|
||||
type="button"
|
||||
key={user.username}
|
||||
className="space-y-2 border border-background hover:border-border hover:bg-border/10 cursor-pointer p-2 text-left"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setUsername(user.username);
|
||||
setPassword(user.password);
|
||||
setLoginDetailsOpen(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={user.name} username={user.username} />
|
||||
<span className="font-semibold">{user.name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
<span className="font-medium text-foreground">
|
||||
Username:
|
||||
</span>{" "}
|
||||
{user.username}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium text-foreground">
|
||||
Password:
|
||||
</span>{" "}
|
||||
{user.password}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col gap-2 items-center border p-6 pb-4",
|
||||
error !== "" && "border-destructive",
|
||||
)}
|
||||
<div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col gap-2 items-center border p-6 pb-4",
|
||||
error !== "" && "border-destructive",
|
||||
)}
|
||||
>
|
||||
<ServerConfiguration />
|
||||
<span className="text-xl font-basteleur mb-2">{capitalise(mode)}</span>
|
||||
|
||||
<div className={"flex flex-col items-center mb-0"}>
|
||||
{mode === "register" && (
|
||||
<>
|
||||
<UploadAvatar
|
||||
name={name}
|
||||
username={username || undefined}
|
||||
avatarURL={avatarURL}
|
||||
onAvatarUploaded={setAvatarUrl}
|
||||
className="mb-2"
|
||||
/>
|
||||
{avatarURL && (
|
||||
<Button
|
||||
variant={"dummy"}
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
setAvatarUrl(null);
|
||||
}}
|
||||
className="-mt-2 mb-2 hover:text-personality"
|
||||
>
|
||||
<ServerConfiguration />
|
||||
<span className="text-xl font-basteleur mb-2">{capitalise(mode)}</span>
|
||||
|
||||
<div className={"flex flex-col items-center mb-0"}>
|
||||
{mode === "register" && (
|
||||
<>
|
||||
<UploadAvatar
|
||||
name={name}
|
||||
username={username || undefined}
|
||||
avatarURL={avatarURL}
|
||||
onAvatarUploaded={setAvatarUrl}
|
||||
className="mb-2"
|
||||
/>
|
||||
{avatarURL && (
|
||||
<Button
|
||||
variant={"dummy"}
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
setAvatarUrl(null);
|
||||
}}
|
||||
className="-mt-2 mb-2 hover:text-personality"
|
||||
>
|
||||
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}
|
||||
spellcheck={false}
|
||||
maxLength={USER_NAME_MAX_LENGTH}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
maxLength={USER_USERNAME_MAX_LENGTH}
|
||||
showCounter={mode === "register"}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
hidden={true}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
<Button variant={"outline"} type={"submit"}>
|
||||
Log in
|
||||
</Button>
|
||||
<Button
|
||||
className="text-xs hover:text-personality p-0"
|
||||
variant={"dummy"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
I don't have an account
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant={"outline"} type={"submit"}>
|
||||
Register
|
||||
</Button>
|
||||
<Button
|
||||
className="text-xs hover:text-personality p-0"
|
||||
variant={"dummy"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
I already have an account
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-4">
|
||||
{error !== "" ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
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}
|
||||
spellcheck={false}
|
||||
maxLength={USER_NAME_MAX_LENGTH}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
maxLength={USER_USERNAME_MAX_LENGTH}
|
||||
showCounter={mode === "register"}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
hidden={true}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
<Button variant={"outline"} type={"submit"}>
|
||||
Log in
|
||||
</Button>
|
||||
<Button
|
||||
className="text-xs hover:text-personality p-0"
|
||||
variant={"dummy"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
I don't have an account
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant={"outline"} type={"submit"}>
|
||||
Register
|
||||
</Button>
|
||||
<Button
|
||||
className="text-xs hover:text-personality p-0"
|
||||
variant={"dummy"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
I already have an account
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-4">
|
||||
{error !== "" ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,69 +4,69 @@ import { IconButton } from "@/components/ui/icon-button";
|
||||
import { UserSelect } from "@/components/user-select";
|
||||
|
||||
export function MultiAssigneeSelect({
|
||||
users,
|
||||
assigneeIds,
|
||||
onChange,
|
||||
fallbackUsers = [],
|
||||
users,
|
||||
assigneeIds,
|
||||
onChange,
|
||||
fallbackUsers = [],
|
||||
}: {
|
||||
users: UserRecord[];
|
||||
assigneeIds: string[];
|
||||
onChange: (assigneeIds: string[]) => void;
|
||||
fallbackUsers?: UserRecord[];
|
||||
users: UserRecord[];
|
||||
assigneeIds: string[];
|
||||
onChange: (assigneeIds: string[]) => void;
|
||||
fallbackUsers?: UserRecord[];
|
||||
}) {
|
||||
const handleAssigneeChange = (index: number, value: string) => {
|
||||
// if set to "unassigned" and there are other rows, remove this row
|
||||
if (value === "unassigned" && assigneeIds.length > 1) {
|
||||
const newAssigneeIds = assigneeIds.filter((_, i) => i !== index);
|
||||
onChange(newAssigneeIds);
|
||||
return;
|
||||
}
|
||||
const handleAssigneeChange = (index: number, value: string) => {
|
||||
// if set to "unassigned" and there are other rows, remove this row
|
||||
if (value === "unassigned" && assigneeIds.length > 1) {
|
||||
const newAssigneeIds = assigneeIds.filter((_, i) => i !== index);
|
||||
onChange(newAssigneeIds);
|
||||
return;
|
||||
}
|
||||
|
||||
const newAssigneeIds = [...assigneeIds];
|
||||
newAssigneeIds[index] = value;
|
||||
onChange(newAssigneeIds);
|
||||
};
|
||||
const newAssigneeIds = [...assigneeIds];
|
||||
newAssigneeIds[index] = value;
|
||||
onChange(newAssigneeIds);
|
||||
};
|
||||
|
||||
const handleAddAssignee = () => {
|
||||
onChange([...assigneeIds, "unassigned"]);
|
||||
};
|
||||
const handleAddAssignee = () => {
|
||||
onChange([...assigneeIds, "unassigned"]);
|
||||
};
|
||||
|
||||
const getAvailableUsers = (currentIndex: number) => {
|
||||
const selectedIds = assigneeIds
|
||||
.filter((_, i) => i !== currentIndex)
|
||||
.filter((id) => id !== "unassigned")
|
||||
.map((id) => Number(id));
|
||||
return users.filter((user) => !selectedIds.includes(user.id));
|
||||
};
|
||||
const getAvailableUsers = (currentIndex: number) => {
|
||||
const selectedIds = assigneeIds
|
||||
.filter((_, i) => i !== currentIndex)
|
||||
.filter((id) => id !== "unassigned")
|
||||
.map((id) => Number(id));
|
||||
return users.filter((user) => !selectedIds.includes(user.id));
|
||||
};
|
||||
|
||||
const getFallbackUser = (assigneeId: string) => {
|
||||
if (assigneeId === "unassigned") return null;
|
||||
return fallbackUsers.find((u) => u.id.toString() === assigneeId) || null;
|
||||
};
|
||||
const getFallbackUser = (assigneeId: string) => {
|
||||
if (assigneeId === "unassigned") return null;
|
||||
return fallbackUsers.find((u) => u.id.toString() === assigneeId) || null;
|
||||
};
|
||||
|
||||
const selectedCount = assigneeIds.filter((id) => id !== "unassigned").length;
|
||||
const lastRowHasSelection = assigneeIds[assigneeIds.length - 1] !== "unassigned";
|
||||
const canAddMore = selectedCount < users.length && lastRowHasSelection;
|
||||
const selectedCount = assigneeIds.filter((id) => id !== "unassigned").length;
|
||||
const lastRowHasSelection = assigneeIds[assigneeIds.length - 1] !== "unassigned";
|
||||
const canAddMore = selectedCount < users.length && lastRowHasSelection;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{assigneeIds.map((assigneeId, index) => (
|
||||
<>
|
||||
<div key={`assignee-${index}-${assigneeId}`} className="flex items-center gap-1">
|
||||
<UserSelect
|
||||
users={getAvailableUsers(index)}
|
||||
value={assigneeId}
|
||||
onChange={(value) => handleAssigneeChange(index, value)}
|
||||
fallbackUser={getFallbackUser(assigneeId)}
|
||||
/>
|
||||
</div>
|
||||
{index === assigneeIds.length - 1 && canAddMore && (
|
||||
<IconButton onClick={handleAddAssignee} title={"Add assignee"} className="w-9 h-9">
|
||||
<Icon icon="plus" className="h-4 w-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{assigneeIds.map((assigneeId, index) => (
|
||||
<>
|
||||
<div key={`assignee-${index}-${assigneeId}`} className="flex items-center gap-1">
|
||||
<UserSelect
|
||||
users={getAvailableUsers(index)}
|
||||
value={assigneeId}
|
||||
onChange={(value) => handleAssigneeChange(index, value)}
|
||||
fallbackUser={getFallbackUser(assigneeId)}
|
||||
/>
|
||||
</div>
|
||||
{index === assigneeIds.length - 1 && canAddMore && (
|
||||
<IconButton onClick={handleAddAssignee} title={"Add assignee"} className="w-9 h-9">
|
||||
<Icon icon="plus" className="h-4 w-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +1,77 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const FALLBACK_COLOURS = [
|
||||
"bg-teal-500",
|
||||
"bg-rose-500",
|
||||
"bg-indigo-500",
|
||||
"bg-amber-500",
|
||||
"bg-cyan-500",
|
||||
"bg-purple-500",
|
||||
"bg-lime-500",
|
||||
"bg-orange-500",
|
||||
"bg-sky-500",
|
||||
"bg-fuchsia-500",
|
||||
"bg-green-500",
|
||||
"bg-red-500",
|
||||
"bg-violet-500",
|
||||
"bg-yellow-500",
|
||||
"bg-blue-500",
|
||||
"bg-emerald-500",
|
||||
"bg-pink-500",
|
||||
"bg-teal-500",
|
||||
"bg-rose-500",
|
||||
"bg-indigo-500",
|
||||
"bg-amber-500",
|
||||
"bg-cyan-500",
|
||||
"bg-purple-500",
|
||||
"bg-lime-500",
|
||||
"bg-orange-500",
|
||||
"bg-sky-500",
|
||||
"bg-fuchsia-500",
|
||||
"bg-green-500",
|
||||
"bg-red-500",
|
||||
"bg-violet-500",
|
||||
"bg-yellow-500",
|
||||
"bg-blue-500",
|
||||
"bg-emerald-500",
|
||||
"bg-pink-500",
|
||||
];
|
||||
|
||||
function hashStringToIndex(value: string, modulo: number) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return modulo === 0 ? 0 : hash % modulo;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return modulo === 0 ? 0 : hash % modulo;
|
||||
}
|
||||
|
||||
function getInitials(name: string) {
|
||||
name = name.trim();
|
||||
name = name.trim();
|
||||
|
||||
const parts = name.split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
||||
if (parts.length === 0) return name.slice(0, 2).toUpperCase();
|
||||
const parts = name.split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
||||
if (parts.length === 0) return name.slice(0, 2).toUpperCase();
|
||||
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
}
|
||||
|
||||
export default function OrgIcon({
|
||||
name,
|
||||
slug,
|
||||
iconURL,
|
||||
size,
|
||||
textClass = "text-md",
|
||||
className,
|
||||
name,
|
||||
slug,
|
||||
iconURL,
|
||||
size,
|
||||
textClass = "text-md",
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
slug: string;
|
||||
iconURL?: string | null;
|
||||
size?: number;
|
||||
textClass?: string;
|
||||
className?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
iconURL?: string | null;
|
||||
size?: number;
|
||||
textClass?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const backgroundClass = FALLBACK_COLOURS[hashStringToIndex(slug, FALLBACK_COLOURS.length)];
|
||||
const backgroundClass = FALLBACK_COLOURS[hashStringToIndex(slug, FALLBACK_COLOURS.length)];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-sm overflow-hidden",
|
||||
"text-white font-medium select-none",
|
||||
!iconURL && backgroundClass,
|
||||
`w-${size || 6}`,
|
||||
`h-${size || 6}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{iconURL ? (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt={name}
|
||||
className={`rounded-md object-cover w-${size || 6} h-${size || 6}`}
|
||||
/>
|
||||
) : (
|
||||
<span className={textClass}>{getInitials(name)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-sm overflow-hidden",
|
||||
"text-white font-medium select-none",
|
||||
!iconURL && backgroundClass,
|
||||
`w-${size || 6}`,
|
||||
`h-${size || 6}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{iconURL ? (
|
||||
<img src={iconURL} alt={name} className={`rounded-md object-cover w-${size || 6} h-${size || 6}`} />
|
||||
) : (
|
||||
<span className={textClass}>{getInitials(name)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
ORG_DESCRIPTION_MAX_LENGTH,
|
||||
ORG_NAME_MAX_LENGTH,
|
||||
ORG_SLUG_MAX_LENGTH,
|
||||
type OrganisationRecord,
|
||||
ORG_DESCRIPTION_MAX_LENGTH,
|
||||
ORG_NAME_MAX_LENGTH,
|
||||
ORG_SLUG_MAX_LENGTH,
|
||||
type OrganisationRecord,
|
||||
} from "@sprint/shared";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field } from "@/components/ui/field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -24,258 +24,258 @@ import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-{2,}/g, "-");
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-{2,}/g, "-");
|
||||
|
||||
export function OrganisationForm({
|
||||
trigger,
|
||||
completeAction,
|
||||
errorAction,
|
||||
mode = "create",
|
||||
existingOrganisation,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
trigger,
|
||||
completeAction,
|
||||
errorAction,
|
||||
mode = "create",
|
||||
existingOrganisation,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: {
|
||||
trigger?: React.ReactNode;
|
||||
completeAction?: (org: OrganisationRecord) => void | Promise<void>;
|
||||
errorAction?: (errorMessage: string) => void | Promise<void>;
|
||||
mode?: "create" | "edit";
|
||||
existingOrganisation?: OrganisationRecord;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
trigger?: React.ReactNode;
|
||||
completeAction?: (org: OrganisationRecord) => void | Promise<void>;
|
||||
errorAction?: (errorMessage: string) => void | Promise<void>;
|
||||
mode?: "create" | "edit";
|
||||
existingOrganisation?: OrganisationRecord;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const { user } = useAuthenticatedSession();
|
||||
const createOrganisation = useCreateOrganisation();
|
||||
const updateOrganisation = useUpdateOrganisation();
|
||||
const { user } = useAuthenticatedSession();
|
||||
const createOrganisation = useCreateOrganisation();
|
||||
const updateOrganisation = useUpdateOrganisation();
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [iconURL, setIconURL] = useState<string | null>(null);
|
||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [iconURL, setIconURL] = useState<string | null>(null);
|
||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = mode === "edit";
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && existingOrganisation && open) {
|
||||
setName(existingOrganisation.name);
|
||||
setSlug(existingOrganisation.slug);
|
||||
setDescription(existingOrganisation.description ?? "");
|
||||
setIconURL(existingOrganisation.iconURL ?? null);
|
||||
setSlugManuallyEdited(true);
|
||||
}
|
||||
}, [isEdit, existingOrganisation, open]);
|
||||
useEffect(() => {
|
||||
if (isEdit && existingOrganisation && open) {
|
||||
setName(existingOrganisation.name);
|
||||
setSlug(existingOrganisation.slug);
|
||||
setDescription(existingOrganisation.description ?? "");
|
||||
setIconURL(existingOrganisation.iconURL ?? null);
|
||||
setSlugManuallyEdited(true);
|
||||
}
|
||||
}, [isEdit, existingOrganisation, open]);
|
||||
|
||||
const reset = () => {
|
||||
setName("");
|
||||
setSlug("");
|
||||
setDescription("");
|
||||
setIconURL(null);
|
||||
setSlugManuallyEdited(false);
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
const reset = () => {
|
||||
setName("");
|
||||
setSlug("");
|
||||
setDescription("");
|
||||
setIconURL(null);
|
||||
setSlugManuallyEdited(false);
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
|
||||
if (name.trim() === "" || name.trim().length > ORG_NAME_MAX_LENGTH) return;
|
||||
if (slug.trim() === "" || slug.trim().length > ORG_SLUG_MAX_LENGTH) return;
|
||||
if (description.trim().length > ORG_DESCRIPTION_MAX_LENGTH) return;
|
||||
if (name.trim() === "" || name.trim().length > ORG_NAME_MAX_LENGTH) return;
|
||||
if (slug.trim() === "" || slug.trim().length > ORG_SLUG_MAX_LENGTH) return;
|
||||
if (description.trim().length > ORG_DESCRIPTION_MAX_LENGTH) return;
|
||||
|
||||
if (!user.id) {
|
||||
setError(`you must be logged in to ${isEdit ? "edit" : "create"} an organisation`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (isEdit && existingOrganisation) {
|
||||
const data = await updateOrganisation.mutateAsync({
|
||||
id: existingOrganisation.id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success("Organisation updated");
|
||||
try {
|
||||
await completeAction?.(data);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
} else {
|
||||
const data = await createOrganisation.mutateAsync({
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success(`Created Organisation ${data.name}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
try {
|
||||
await completeAction?.(data);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
console.error(err);
|
||||
setError(message || `failed to ${isEdit ? "update" : "create"} organisation`);
|
||||
setSubmitting(false);
|
||||
try {
|
||||
await errorAction?.(message || `failed to ${isEdit ? "update" : "create"} organisation`);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dialogContent = (
|
||||
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Organisation" : "Create Organisation"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid mt-2">
|
||||
{isEdit && existingOrganisation && (
|
||||
<UploadOrgIcon
|
||||
name={name || existingOrganisation.name}
|
||||
slug={slug || existingOrganisation.slug}
|
||||
iconURL={iconURL}
|
||||
organisationId={existingOrganisation.id}
|
||||
onIconUploaded={setIconURL}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
const nextName = e.target.value;
|
||||
setName(nextName);
|
||||
if (!slugManuallyEdited) {
|
||||
setSlug(slugify(nextName));
|
||||
}
|
||||
}}
|
||||
validate={(v) => {
|
||||
if (v.trim() === "") return "Cannot be empty";
|
||||
if (v.trim().length > ORG_NAME_MAX_LENGTH) {
|
||||
return `Too long (${ORG_NAME_MAX_LENGTH} character limit)`;
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Demo Organisation"
|
||||
maxLength={ORG_NAME_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Slug"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlug(slugify(e.target.value));
|
||||
setSlugManuallyEdited(true);
|
||||
}}
|
||||
validate={(v) => {
|
||||
if (v.trim() === "") return "Cannot be empty";
|
||||
if (v.trim().length > ORG_SLUG_MAX_LENGTH) {
|
||||
return `Too long (${ORG_SLUG_MAX_LENGTH} character limit)`;
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="demo-organisation"
|
||||
maxLength={ORG_SLUG_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
validate={(v) => {
|
||||
if (v.trim().length > ORG_DESCRIPTION_MAX_LENGTH) {
|
||||
return `Too long (${ORG_DESCRIPTION_MAX_LENGTH} character limit)`;
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="What is this organisation for?"
|
||||
maxLength={ORG_DESCRIPTION_MAX_LENGTH}
|
||||
/>
|
||||
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||
{error ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
name.trim() === "" ||
|
||||
name.trim().length > ORG_NAME_MAX_LENGTH ||
|
||||
slug.trim() === "" ||
|
||||
slug.trim().length > ORG_SLUG_MAX_LENGTH ||
|
||||
description.trim().length > ORG_DESCRIPTION_MAX_LENGTH
|
||||
}
|
||||
>
|
||||
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
if (isControlled) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
if (!user.id) {
|
||||
setError(`you must be logged in to ${isEdit ? "edit" : "create"} an organisation`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (isEdit && existingOrganisation) {
|
||||
const data = await updateOrganisation.mutateAsync({
|
||||
id: existingOrganisation.id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success("Organisation updated");
|
||||
try {
|
||||
await completeAction?.(data);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
} else {
|
||||
const data = await createOrganisation.mutateAsync({
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success(`Created Organisation ${data.name}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
try {
|
||||
await completeAction?.(data);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
console.error(err);
|
||||
setError(message || `failed to ${isEdit ? "update" : "create"} organisation`);
|
||||
setSubmitting(false);
|
||||
try {
|
||||
await errorAction?.(message || `failed to ${isEdit ? "update" : "create"} organisation`);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dialogContent = (
|
||||
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Organisation" : "Create Organisation"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid mt-2">
|
||||
{isEdit && existingOrganisation && (
|
||||
<UploadOrgIcon
|
||||
name={name || existingOrganisation.name}
|
||||
slug={slug || existingOrganisation.slug}
|
||||
iconURL={iconURL}
|
||||
organisationId={existingOrganisation.id}
|
||||
onIconUploaded={setIconURL}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
const nextName = e.target.value;
|
||||
setName(nextName);
|
||||
if (!slugManuallyEdited) {
|
||||
setSlug(slugify(nextName));
|
||||
}
|
||||
}}
|
||||
validate={(v) => {
|
||||
if (v.trim() === "") return "Cannot be empty";
|
||||
if (v.trim().length > ORG_NAME_MAX_LENGTH) {
|
||||
return `Too long (${ORG_NAME_MAX_LENGTH} character limit)`;
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Demo Organisation"
|
||||
maxLength={ORG_NAME_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Slug"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlug(slugify(e.target.value));
|
||||
setSlugManuallyEdited(true);
|
||||
}}
|
||||
validate={(v) => {
|
||||
if (v.trim() === "") return "Cannot be empty";
|
||||
if (v.trim().length > ORG_SLUG_MAX_LENGTH) {
|
||||
return `Too long (${ORG_SLUG_MAX_LENGTH} character limit)`;
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="demo-organisation"
|
||||
maxLength={ORG_SLUG_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
validate={(v) => {
|
||||
if (v.trim().length > ORG_DESCRIPTION_MAX_LENGTH) {
|
||||
return `Too long (${ORG_DESCRIPTION_MAX_LENGTH} character limit)`;
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="What is this organisation for?"
|
||||
maxLength={ORG_DESCRIPTION_MAX_LENGTH}
|
||||
/>
|
||||
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||
{error ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
name.trim() === "" ||
|
||||
name.trim().length > ORG_NAME_MAX_LENGTH ||
|
||||
slug.trim() === "" ||
|
||||
slug.trim().length > ORG_SLUG_MAX_LENGTH ||
|
||||
description.trim().length > ORG_DESCRIPTION_MAX_LENGTH
|
||||
}
|
||||
>
|
||||
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
if (isControlled) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || <Button variant="outline">Create Organisation</Button>}
|
||||
</DialogTrigger>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || <Button variant="outline">Create Organisation</Button>}
|
||||
</DialogTrigger>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,132 +4,129 @@ import { OrganisationForm } from "@/components/organisation-form";
|
||||
import { useSelection } from "@/components/selection-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useOrganisations } from "@/lib/query/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import OrgIcon from "./org-icon";
|
||||
|
||||
export function OrganisationSelect({
|
||||
placeholder = "Select Organisation",
|
||||
contentClass,
|
||||
showLabel = false,
|
||||
label = "Organisation",
|
||||
labelPosition = "top",
|
||||
triggerClassName,
|
||||
noDecoration,
|
||||
trigger,
|
||||
placeholder = "Select Organisation",
|
||||
contentClass,
|
||||
showLabel = false,
|
||||
label = "Organisation",
|
||||
labelPosition = "top",
|
||||
triggerClassName,
|
||||
noDecoration,
|
||||
trigger,
|
||||
}: {
|
||||
placeholder?: string;
|
||||
contentClass?: string;
|
||||
showLabel?: boolean;
|
||||
label?: string;
|
||||
labelPosition?: "top" | "bottom";
|
||||
triggerClassName?: string;
|
||||
noDecoration?: boolean;
|
||||
trigger?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
contentClass?: string;
|
||||
showLabel?: boolean;
|
||||
label?: string;
|
||||
labelPosition?: "top" | "bottom";
|
||||
triggerClassName?: string;
|
||||
noDecoration?: boolean;
|
||||
trigger?: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
const { selectedOrganisationId, selectOrganisation } = useSelection();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
const { selectedOrganisationId, selectOrganisation } = useSelection();
|
||||
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
[organisationsData],
|
||||
);
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
[organisationsData],
|
||||
);
|
||||
|
||||
const selectedOrganisation = useMemo(
|
||||
() => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
|
||||
[organisations, selectedOrganisationId],
|
||||
);
|
||||
const selectedOrganisation = useMemo(
|
||||
() => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
|
||||
[organisations, selectedOrganisationId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingOrganisationId) return;
|
||||
const organisation = organisations.find((org) => org.Organisation.id === pendingOrganisationId);
|
||||
if (organisation) {
|
||||
selectOrganisation(organisation);
|
||||
setPendingOrganisationId(null);
|
||||
useEffect(() => {
|
||||
if (!pendingOrganisationId) return;
|
||||
const organisation = organisations.find((org) => org.Organisation.id === pendingOrganisationId);
|
||||
if (organisation) {
|
||||
selectOrganisation(organisation);
|
||||
setPendingOrganisationId(null);
|
||||
}
|
||||
}, [organisations, pendingOrganisationId, selectOrganisation]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedOrganisation ? `${selectedOrganisation.Organisation.id}` : undefined}
|
||||
onValueChange={(value) => {
|
||||
const organisation = organisations.find((o) => o.Organisation.id === Number(value));
|
||||
if (!organisation) {
|
||||
console.error(`NO ORGANISATION FOUND FOR ID: ${value}`);
|
||||
return;
|
||||
}
|
||||
}, [organisations, pendingOrganisationId, selectOrganisation]);
|
||||
selectOrganisation(organisation);
|
||||
}}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"text-sm",
|
||||
noDecoration &&
|
||||
"bg-transparent border-0 px-0 focus:ring-0 hover:bg-transparent px-0 py-0 w-min h-min",
|
||||
triggerClassName,
|
||||
)}
|
||||
isOpen={open}
|
||||
label={showLabel ? label : undefined}
|
||||
hasValue={!!selectedOrganisation}
|
||||
labelPosition={labelPosition}
|
||||
chevronClassName={cn(noDecoration && "hidden")}
|
||||
>
|
||||
{trigger ? trigger : <SelectValue placeholder={placeholder} />}
|
||||
</SelectTrigger>
|
||||
<SelectContent side="bottom" position="popper" className={contentClass}>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Organisations</SelectLabel>
|
||||
{organisations.map((organisation) => (
|
||||
<SelectItem key={organisation.Organisation.id} value={`${organisation.Organisation.id}`}>
|
||||
<OrgIcon
|
||||
name={organisation.Organisation.name}
|
||||
slug={organisation.Organisation.slug}
|
||||
iconURL={organisation.Organisation.iconURL}
|
||||
size={6}
|
||||
textClass="text-sm"
|
||||
/>
|
||||
{organisation.Organisation.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedOrganisation ? `${selectedOrganisation.Organisation.id}` : undefined}
|
||||
onValueChange={(value) => {
|
||||
const organisation = organisations.find((o) => o.Organisation.id === Number(value));
|
||||
if (!organisation) {
|
||||
console.error(`NO ORGANISATION FOUND FOR ID: ${value}`);
|
||||
return;
|
||||
}
|
||||
selectOrganisation(organisation);
|
||||
}}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"text-sm",
|
||||
noDecoration &&
|
||||
"bg-transparent border-0 px-0 focus:ring-0 hover:bg-transparent px-0 py-0 w-min h-min",
|
||||
triggerClassName,
|
||||
)}
|
||||
isOpen={open}
|
||||
label={showLabel ? label : undefined}
|
||||
hasValue={!!selectedOrganisation}
|
||||
labelPosition={labelPosition}
|
||||
chevronClassName={cn(noDecoration && "hidden")}
|
||||
>
|
||||
{trigger ? trigger : <SelectValue placeholder={placeholder} />}
|
||||
</SelectTrigger>
|
||||
<SelectContent side="bottom" position="popper" className={contentClass}>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Organisations</SelectLabel>
|
||||
{organisations.map((organisation) => (
|
||||
<SelectItem
|
||||
key={organisation.Organisation.id}
|
||||
value={`${organisation.Organisation.id}`}
|
||||
>
|
||||
<OrgIcon
|
||||
name={organisation.Organisation.name}
|
||||
slug={organisation.Organisation.slug}
|
||||
iconURL={organisation.Organisation.iconURL}
|
||||
size={6}
|
||||
textClass="text-sm"
|
||||
/>
|
||||
{organisation.Organisation.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{organisations.length > 0 && <SelectSeparator />}
|
||||
</SelectGroup>
|
||||
|
||||
{organisations.length > 0 && <SelectSeparator />}
|
||||
</SelectGroup>
|
||||
|
||||
<OrganisationForm
|
||||
trigger={
|
||||
<Button variant="ghost" className={"w-full"} size={"sm"}>
|
||||
Create Organisation
|
||||
</Button>
|
||||
}
|
||||
completeAction={async (org) => {
|
||||
try {
|
||||
setPendingOrganisationId(org.id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}}
|
||||
errorAction={async (errorMessage) => {
|
||||
toast.error(`Error creating organisation: ${errorMessage}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
<OrganisationForm
|
||||
trigger={
|
||||
<Button variant="ghost" className={"w-full"} size={"sm"}>
|
||||
Create Organisation
|
||||
</Button>
|
||||
}
|
||||
completeAction={async (org) => {
|
||||
try {
|
||||
setPendingOrganisationId(org.id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}}
|
||||
errorAction={async (errorMessage) => {
|
||||
toast.error(`Error creating organisation: ${errorMessage}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,12 @@ import { toast } from "sonner";
|
||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field } from "@/components/ui/field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -18,236 +18,236 @@ import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const keyify = (value: string) =>
|
||||
value
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, "")
|
||||
.slice(0, 4);
|
||||
value
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, "")
|
||||
.slice(0, 4);
|
||||
|
||||
export function ProjectForm({
|
||||
organisationId,
|
||||
trigger,
|
||||
completeAction,
|
||||
mode = "create",
|
||||
existingProject,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
organisationId,
|
||||
trigger,
|
||||
completeAction,
|
||||
mode = "create",
|
||||
existingProject,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: {
|
||||
organisationId?: number;
|
||||
trigger?: React.ReactNode;
|
||||
completeAction?: (project: ProjectRecord) => void | Promise<void>;
|
||||
mode?: "create" | "edit";
|
||||
existingProject?: ProjectRecord;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
organisationId?: number;
|
||||
trigger?: React.ReactNode;
|
||||
completeAction?: (project: ProjectRecord) => void | Promise<void>;
|
||||
mode?: "create" | "edit";
|
||||
existingProject?: ProjectRecord;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const { user } = useAuthenticatedSession();
|
||||
const createProject = useCreateProject();
|
||||
const updateProject = useUpdateProject();
|
||||
const { user } = useAuthenticatedSession();
|
||||
const createProject = useCreateProject();
|
||||
const updateProject = useUpdateProject();
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [keyManuallyEdited, setKeyManuallyEdited] = useState(false);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [keyManuallyEdited, setKeyManuallyEdited] = useState(false);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = mode === "edit";
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && existingProject && open) {
|
||||
setName(existingProject.name);
|
||||
setKey(existingProject.key);
|
||||
setKeyManuallyEdited(true);
|
||||
}
|
||||
}, [isEdit, existingProject, open]);
|
||||
useEffect(() => {
|
||||
if (isEdit && existingProject && open) {
|
||||
setName(existingProject.name);
|
||||
setKey(existingProject.key);
|
||||
setKeyManuallyEdited(true);
|
||||
}
|
||||
}, [isEdit, existingProject, open]);
|
||||
|
||||
const reset = () => {
|
||||
setName("");
|
||||
setKey("");
|
||||
setKeyManuallyEdited(false);
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
const reset = () => {
|
||||
setName("");
|
||||
setKey("");
|
||||
setKeyManuallyEdited(false);
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
|
||||
if (
|
||||
name.trim() === "" ||
|
||||
name.trim().length > PROJECT_NAME_MAX_LENGTH ||
|
||||
key.trim() === "" ||
|
||||
key.length > 4
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.id) {
|
||||
setError(`you must be logged in to ${isEdit ? "edit" : "create"} a project`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEdit && !organisationId) {
|
||||
setError("select an organisation first");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (isEdit && existingProject) {
|
||||
const proj = await updateProject.mutateAsync({
|
||||
id: existingProject.id,
|
||||
key,
|
||||
name,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success("Project updated");
|
||||
try {
|
||||
await completeAction?.(proj);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
} else {
|
||||
if (!organisationId) {
|
||||
setError("select an organisation first");
|
||||
return;
|
||||
}
|
||||
const proj = await createProject.mutateAsync({
|
||||
key,
|
||||
name,
|
||||
organisationId,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success(`Created Project ${proj.name}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
try {
|
||||
await completeAction?.(proj);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
console.error(err);
|
||||
setError(message || `failed to ${isEdit ? "update" : "create"} project`);
|
||||
setSubmitting(false);
|
||||
toast.error(`Error ${isEdit ? "updating" : "creating"} project: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const dialogContent = (
|
||||
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Project" : "Create Project"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid mt-2">
|
||||
<Field
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
const nextName = e.target.value;
|
||||
setName(nextName);
|
||||
if (!keyManuallyEdited) {
|
||||
setKey(keyify(nextName));
|
||||
}
|
||||
}}
|
||||
validate={(v) => {
|
||||
if (v.trim() === "") return "Cannot be empty";
|
||||
if (v.trim().length > PROJECT_NAME_MAX_LENGTH) {
|
||||
return `Too long (${PROJECT_NAME_MAX_LENGTH} character limit)`;
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Demo Project"
|
||||
maxLength={PROJECT_NAME_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Key"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(keyify(e.target.value));
|
||||
setKeyManuallyEdited(true);
|
||||
}}
|
||||
validate={(v) => {
|
||||
if (v.trim() === "") return "Cannot be empty";
|
||||
if (v.length > 4) return "Must be 4 or less characters";
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="DEMO"
|
||||
/>
|
||||
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||
{error ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
(name.trim() === "" && submitAttempted) ||
|
||||
(name.trim().length > PROJECT_NAME_MAX_LENGTH && submitAttempted) ||
|
||||
((key.trim() === "" || key.length > 4) && submitAttempted)
|
||||
}
|
||||
>
|
||||
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
if (isControlled) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
if (
|
||||
name.trim() === "" ||
|
||||
name.trim().length > PROJECT_NAME_MAX_LENGTH ||
|
||||
key.trim() === "" ||
|
||||
key.length > 4
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.id) {
|
||||
setError(`you must be logged in to ${isEdit ? "edit" : "create"} a project`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEdit && !organisationId) {
|
||||
setError("select an organisation first");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (isEdit && existingProject) {
|
||||
const proj = await updateProject.mutateAsync({
|
||||
id: existingProject.id,
|
||||
key,
|
||||
name,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success("Project updated");
|
||||
try {
|
||||
await completeAction?.(proj);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
} else {
|
||||
if (!organisationId) {
|
||||
setError("select an organisation first");
|
||||
return;
|
||||
}
|
||||
const proj = await createProject.mutateAsync({
|
||||
key,
|
||||
name,
|
||||
organisationId,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success(`Created Project ${proj.name}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
try {
|
||||
await completeAction?.(proj);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
console.error(err);
|
||||
setError(message || `failed to ${isEdit ? "update" : "create"} project`);
|
||||
setSubmitting(false);
|
||||
toast.error(`Error ${isEdit ? "updating" : "creating"} project: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const dialogContent = (
|
||||
<DialogContent className={cn("w-md", error ? "border-destructive" : "")}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Project" : "Create Project"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid mt-2">
|
||||
<Field
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
const nextName = e.target.value;
|
||||
setName(nextName);
|
||||
if (!keyManuallyEdited) {
|
||||
setKey(keyify(nextName));
|
||||
}
|
||||
}}
|
||||
validate={(v) => {
|
||||
if (v.trim() === "") return "Cannot be empty";
|
||||
if (v.trim().length > PROJECT_NAME_MAX_LENGTH) {
|
||||
return `Too long (${PROJECT_NAME_MAX_LENGTH} character limit)`;
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Demo Project"
|
||||
maxLength={PROJECT_NAME_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Key"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(keyify(e.target.value));
|
||||
setKeyManuallyEdited(true);
|
||||
}}
|
||||
validate={(v) => {
|
||||
if (v.trim() === "") return "Cannot be empty";
|
||||
if (v.length > 4) return "Must be 4 or less characters";
|
||||
return undefined;
|
||||
}}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="DEMO"
|
||||
/>
|
||||
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||
{error ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
(name.trim() === "" && submitAttempted) ||
|
||||
(name.trim().length > PROJECT_NAME_MAX_LENGTH && submitAttempted) ||
|
||||
((key.trim() === "" || key.length > 4) && submitAttempted)
|
||||
}
|
||||
>
|
||||
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
if (isControlled) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" disabled={!organisationId}>
|
||||
Create Project
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" disabled={!organisationId}>
|
||||
Create Project
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,105 +3,100 @@ import { ProjectForm } from "@/components/project-form";
|
||||
import { useSelection } from "@/components/selection-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useProjects } from "@/lib/query/hooks";
|
||||
|
||||
export function ProjectSelect({
|
||||
placeholder = "Select Project",
|
||||
showLabel = false,
|
||||
label = "Project",
|
||||
labelPosition = "top",
|
||||
placeholder = "Select Project",
|
||||
showLabel = false,
|
||||
label = "Project",
|
||||
labelPosition = "top",
|
||||
}: {
|
||||
placeholder?: string;
|
||||
showLabel?: boolean;
|
||||
label?: string;
|
||||
labelPosition?: "top" | "bottom";
|
||||
placeholder?: string;
|
||||
showLabel?: boolean;
|
||||
label?: string;
|
||||
labelPosition?: "top" | "bottom";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
|
||||
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
|
||||
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
|
||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||
|
||||
const projects = useMemo(
|
||||
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||
[projectsData],
|
||||
);
|
||||
const projects = useMemo(
|
||||
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||
[projectsData],
|
||||
);
|
||||
|
||||
const selectedProject = useMemo(
|
||||
() => projects.find((proj) => proj.Project.id === selectedProjectId) ?? null,
|
||||
[projects, selectedProjectId],
|
||||
);
|
||||
const selectedProject = useMemo(
|
||||
() => projects.find((proj) => proj.Project.id === selectedProjectId) ?? null,
|
||||
[projects, selectedProjectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingProjectId) return;
|
||||
const project = projects.find((proj) => proj.Project.id === pendingProjectId);
|
||||
if (project) {
|
||||
selectProject(project);
|
||||
setPendingProjectId(null);
|
||||
useEffect(() => {
|
||||
if (!pendingProjectId) return;
|
||||
const project = projects.find((proj) => proj.Project.id === pendingProjectId);
|
||||
if (project) {
|
||||
selectProject(project);
|
||||
setPendingProjectId(null);
|
||||
}
|
||||
}, [pendingProjectId, projects, selectProject]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedProject ? `${selectedProject.Project.id}` : undefined}
|
||||
onValueChange={(value) => {
|
||||
const project = projects.find((p) => p.Project.id === Number(value));
|
||||
if (!project) {
|
||||
console.error(`NO PROJECT FOUND FOR ID: ${value}`);
|
||||
return;
|
||||
}
|
||||
}, [pendingProjectId, projects, selectProject]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedProject ? `${selectedProject.Project.id}` : undefined}
|
||||
onValueChange={(value) => {
|
||||
const project = projects.find((p) => p.Project.id === Number(value));
|
||||
if (!project) {
|
||||
console.error(`NO PROJECT FOUND FOR ID: ${value}`);
|
||||
return;
|
||||
}
|
||||
selectProject(project);
|
||||
}}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="text-sm"
|
||||
isOpen={open}
|
||||
label={showLabel ? label : undefined}
|
||||
hasValue={!!selectedProject}
|
||||
labelPosition={labelPosition}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="bottom" position="popper" align={"start"}>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Projects</SelectLabel>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.Project.id} value={`${project.Project.id}`}>
|
||||
{project.Project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{projects.length > 0 && <SelectSeparator />}
|
||||
</SelectGroup>
|
||||
<ProjectForm
|
||||
organisationId={selectedOrganisationId ?? undefined}
|
||||
trigger={
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant="ghost"
|
||||
className={"w-full"}
|
||||
disabled={!selectedOrganisationId}
|
||||
>
|
||||
Create Project
|
||||
</Button>
|
||||
}
|
||||
completeAction={async (project) => {
|
||||
try {
|
||||
setPendingProjectId(project.id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
selectProject(project);
|
||||
}}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="text-sm"
|
||||
isOpen={open}
|
||||
label={showLabel ? label : undefined}
|
||||
hasValue={!!selectedProject}
|
||||
labelPosition={labelPosition}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="bottom" position="popper" align={"start"}>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Projects</SelectLabel>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.Project.id} value={`${project.Project.id}`}>
|
||||
{project.Project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{projects.length > 0 && <SelectSeparator />}
|
||||
</SelectGroup>
|
||||
<ProjectForm
|
||||
organisationId={selectedOrganisationId ?? undefined}
|
||||
trigger={
|
||||
<Button size={"sm"} variant="ghost" className={"w-full"} disabled={!selectedOrganisationId}>
|
||||
Create Project
|
||||
</Button>
|
||||
}
|
||||
completeAction={async (project) => {
|
||||
try {
|
||||
setPendingProjectId(project.id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { ReactNode } from "react";
|
||||
import { queryClient } from "@/lib/query/client";
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,152 +3,152 @@ import type { ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
|
||||
type SelectionContextValue = {
|
||||
selectedOrganisationId: number | null;
|
||||
selectedProjectId: number | null;
|
||||
selectedIssueId: number | null;
|
||||
initialParams: {
|
||||
orgSlug: string;
|
||||
projectKey: string;
|
||||
issueNumber: number | null;
|
||||
};
|
||||
selectOrganisation: (organisation: OrganisationResponse | null, options?: SelectionOptions) => void;
|
||||
selectProject: (project: ProjectResponse | null, options?: SelectionOptions) => void;
|
||||
selectIssue: (issue: IssueResponse | null, options?: SelectionOptions) => void;
|
||||
selectedOrganisationId: number | null;
|
||||
selectedProjectId: number | null;
|
||||
selectedIssueId: number | null;
|
||||
initialParams: {
|
||||
orgSlug: string;
|
||||
projectKey: string;
|
||||
issueNumber: number | null;
|
||||
};
|
||||
selectOrganisation: (organisation: OrganisationResponse | null, options?: SelectionOptions) => void;
|
||||
selectProject: (project: ProjectResponse | null, options?: SelectionOptions) => void;
|
||||
selectIssue: (issue: IssueResponse | null, options?: SelectionOptions) => void;
|
||||
};
|
||||
|
||||
type SelectionOptions = {
|
||||
skipUrlUpdate?: boolean;
|
||||
skipUrlUpdate?: boolean;
|
||||
};
|
||||
|
||||
const SelectionContext = createContext<SelectionContextValue | null>(null);
|
||||
|
||||
const readStoredId = (key: string) => {
|
||||
const value = localStorage.getItem(key);
|
||||
if (!value) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
const value = localStorage.getItem(key);
|
||||
if (!value) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
const updateUrlParams = (updates: {
|
||||
orgSlug?: string | null;
|
||||
projectKey?: string | null;
|
||||
issueNumber?: number | null;
|
||||
orgSlug?: string | null;
|
||||
projectKey?: string | null;
|
||||
issueNumber?: number | null;
|
||||
}) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
if (updates.orgSlug !== undefined) {
|
||||
if (updates.orgSlug) params.set("o", updates.orgSlug);
|
||||
else params.delete("o");
|
||||
}
|
||||
if (updates.orgSlug !== undefined) {
|
||||
if (updates.orgSlug) params.set("o", updates.orgSlug);
|
||||
else params.delete("o");
|
||||
}
|
||||
|
||||
if (updates.projectKey !== undefined) {
|
||||
if (updates.projectKey) params.set("p", updates.projectKey);
|
||||
else params.delete("p");
|
||||
}
|
||||
if (updates.projectKey !== undefined) {
|
||||
if (updates.projectKey) params.set("p", updates.projectKey);
|
||||
else params.delete("p");
|
||||
}
|
||||
|
||||
if (updates.issueNumber !== undefined) {
|
||||
if (updates.issueNumber != null) params.set("i", `${updates.issueNumber}`);
|
||||
else params.delete("i");
|
||||
}
|
||||
if (updates.issueNumber !== undefined) {
|
||||
if (updates.issueNumber != null) params.set("i", `${updates.issueNumber}`);
|
||||
else params.delete("i");
|
||||
}
|
||||
|
||||
const search = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${search ? `?${search}` : ""}`;
|
||||
window.history.replaceState(null, "", nextUrl);
|
||||
const search = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${search ? `?${search}` : ""}`;
|
||||
window.history.replaceState(null, "", nextUrl);
|
||||
};
|
||||
|
||||
export function SelectionProvider({ children }: { children: ReactNode }) {
|
||||
const initialParams = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const orgSlug = params.get("o")?.trim().toLowerCase() ?? "";
|
||||
const projectKey = params.get("p")?.trim().toLowerCase() ?? "";
|
||||
const issueParam = params.get("i")?.trim() ?? "";
|
||||
const issueNumber = issueParam === "" ? null : Number.parseInt(issueParam, 10);
|
||||
const initialParams = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const orgSlug = params.get("o")?.trim().toLowerCase() ?? "";
|
||||
const projectKey = params.get("p")?.trim().toLowerCase() ?? "";
|
||||
const issueParam = params.get("i")?.trim() ?? "";
|
||||
const issueNumber = issueParam === "" ? null : Number.parseInt(issueParam, 10);
|
||||
|
||||
return {
|
||||
orgSlug,
|
||||
projectKey,
|
||||
issueNumber: issueNumber != null && Number.isNaN(issueNumber) ? null : issueNumber,
|
||||
};
|
||||
}, []);
|
||||
return {
|
||||
orgSlug,
|
||||
projectKey,
|
||||
issueNumber: issueNumber != null && Number.isNaN(issueNumber) ? null : issueNumber,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [selectedOrganisationId, setSelectedOrganisationId] = useState<number | null>(() =>
|
||||
readStoredId("selectedOrganisationId"),
|
||||
);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(() =>
|
||||
readStoredId("selectedProjectId"),
|
||||
);
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<number | null>(null);
|
||||
const [selectedOrganisationId, setSelectedOrganisationId] = useState<number | null>(() =>
|
||||
readStoredId("selectedOrganisationId"),
|
||||
);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(() =>
|
||||
readStoredId("selectedProjectId"),
|
||||
);
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<number | null>(null);
|
||||
|
||||
const selectOrganisation = useCallback(
|
||||
(organisation: OrganisationResponse | null, options?: SelectionOptions) => {
|
||||
const id = organisation?.Organisation.id ?? null;
|
||||
setSelectedOrganisationId(id);
|
||||
setSelectedProjectId(null);
|
||||
setSelectedIssueId(null);
|
||||
if (id != null) localStorage.setItem("selectedOrganisationId", `${id}`);
|
||||
else localStorage.removeItem("selectedOrganisationId");
|
||||
localStorage.removeItem("selectedProjectId");
|
||||
if (!options?.skipUrlUpdate) {
|
||||
updateUrlParams({
|
||||
orgSlug: organisation?.Organisation.slug.toLowerCase() ?? null,
|
||||
projectKey: null,
|
||||
issueNumber: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const selectOrganisation = useCallback(
|
||||
(organisation: OrganisationResponse | null, options?: SelectionOptions) => {
|
||||
const id = organisation?.Organisation.id ?? null;
|
||||
setSelectedOrganisationId(id);
|
||||
setSelectedProjectId(null);
|
||||
setSelectedIssueId(null);
|
||||
if (id != null) localStorage.setItem("selectedOrganisationId", `${id}`);
|
||||
else localStorage.removeItem("selectedOrganisationId");
|
||||
localStorage.removeItem("selectedProjectId");
|
||||
if (!options?.skipUrlUpdate) {
|
||||
updateUrlParams({
|
||||
orgSlug: organisation?.Organisation.slug.toLowerCase() ?? null,
|
||||
projectKey: null,
|
||||
issueNumber: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectProject = useCallback((project: ProjectResponse | null, options?: SelectionOptions) => {
|
||||
const id = project?.Project.id ?? null;
|
||||
setSelectedProjectId(id);
|
||||
setSelectedIssueId(null);
|
||||
if (id != null) localStorage.setItem("selectedProjectId", `${id}`);
|
||||
else localStorage.removeItem("selectedProjectId");
|
||||
if (!options?.skipUrlUpdate) {
|
||||
updateUrlParams({
|
||||
projectKey: project?.Project.key.toLowerCase() ?? null,
|
||||
issueNumber: null,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const selectProject = useCallback((project: ProjectResponse | null, options?: SelectionOptions) => {
|
||||
const id = project?.Project.id ?? null;
|
||||
setSelectedProjectId(id);
|
||||
setSelectedIssueId(null);
|
||||
if (id != null) localStorage.setItem("selectedProjectId", `${id}`);
|
||||
else localStorage.removeItem("selectedProjectId");
|
||||
if (!options?.skipUrlUpdate) {
|
||||
updateUrlParams({
|
||||
projectKey: project?.Project.key.toLowerCase() ?? null,
|
||||
issueNumber: null,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectIssue = useCallback((issue: IssueResponse | null, options?: SelectionOptions) => {
|
||||
const id = issue?.Issue.id ?? null;
|
||||
setSelectedIssueId(id);
|
||||
if (!options?.skipUrlUpdate) {
|
||||
updateUrlParams({ issueNumber: issue?.Issue.number ?? null });
|
||||
}
|
||||
}, []);
|
||||
const selectIssue = useCallback((issue: IssueResponse | null, options?: SelectionOptions) => {
|
||||
const id = issue?.Issue.id ?? null;
|
||||
setSelectedIssueId(id);
|
||||
if (!options?.skipUrlUpdate) {
|
||||
updateUrlParams({ issueNumber: issue?.Issue.number ?? null });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<SelectionContextValue>(
|
||||
() => ({
|
||||
selectedOrganisationId,
|
||||
selectedProjectId,
|
||||
selectedIssueId,
|
||||
initialParams,
|
||||
selectOrganisation,
|
||||
selectProject,
|
||||
selectIssue,
|
||||
}),
|
||||
[
|
||||
selectedOrganisationId,
|
||||
selectedProjectId,
|
||||
selectedIssueId,
|
||||
initialParams,
|
||||
selectOrganisation,
|
||||
selectProject,
|
||||
selectIssue,
|
||||
],
|
||||
);
|
||||
const value = useMemo<SelectionContextValue>(
|
||||
() => ({
|
||||
selectedOrganisationId,
|
||||
selectedProjectId,
|
||||
selectedIssueId,
|
||||
initialParams,
|
||||
selectOrganisation,
|
||||
selectProject,
|
||||
selectIssue,
|
||||
}),
|
||||
[
|
||||
selectedOrganisationId,
|
||||
selectedProjectId,
|
||||
selectedIssueId,
|
||||
initialParams,
|
||||
selectOrganisation,
|
||||
selectProject,
|
||||
selectIssue,
|
||||
],
|
||||
);
|
||||
|
||||
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
|
||||
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSelection() {
|
||||
const context = useContext(SelectionContext);
|
||||
if (!context) {
|
||||
throw new Error("useSelection must be used within SelectionProvider");
|
||||
}
|
||||
return context;
|
||||
const context = useContext(SelectionContext);
|
||||
if (!context) {
|
||||
throw new Error("useSelection must be used within SelectionProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -10,176 +10,170 @@ import { getServerURL } from "@/lib/utils";
|
||||
const DEFAULT_URL = "https://tnirps.ob248.com";
|
||||
|
||||
const formatURL = (url: string) => {
|
||||
if (url.endsWith("/")) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
if (
|
||||
(url.includes("localhost") || url.includes("127.0.0.1")) &&
|
||||
!url.startsWith("http://") &&
|
||||
!url.startsWith("https://")
|
||||
) {
|
||||
url = `http://${url}`; // use http for localhost
|
||||
} else if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = `https://${url}`; // assume https if none is provided
|
||||
}
|
||||
return url;
|
||||
if (url.endsWith("/")) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
if (
|
||||
(url.includes("localhost") || url.includes("127.0.0.1")) &&
|
||||
!url.startsWith("http://") &&
|
||||
!url.startsWith("https://")
|
||||
) {
|
||||
url = `http://${url}`; // use http for localhost
|
||||
} else if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = `https://${url}`; // assume https if none is provided
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const isValidURL = (url: string) => {
|
||||
try {
|
||||
new URL(formatURL(url));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new URL(formatURL(url));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export function ServerConfiguration({ trigger }: { trigger?: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [serverURL, setServerURL] = useState(getServerURL());
|
||||
const [originalURL, setOriginalURL] = useState(getServerURL());
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isCheckingHealth, setIsCheckingHealth] = useState(false);
|
||||
const [healthError, setHealthError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [serverURL, setServerURL] = useState(getServerURL());
|
||||
const [originalURL, setOriginalURL] = useState(getServerURL());
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isCheckingHealth, setIsCheckingHealth] = useState(false);
|
||||
const [healthError, setHealthError] = useState<string | null>(null);
|
||||
|
||||
const hasChanged = formatURL(serverURL) !== formatURL(originalURL);
|
||||
const isNotDefault = formatURL(serverURL) !== formatURL(DEFAULT_URL);
|
||||
const canSave = hasChanged && isValidURL(serverURL);
|
||||
const hasChanged = formatURL(serverURL) !== formatURL(originalURL);
|
||||
const isNotDefault = formatURL(serverURL) !== formatURL(DEFAULT_URL);
|
||||
const canSave = hasChanged && isValidURL(serverURL);
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setServerURL(getServerURL());
|
||||
setOriginalURL(getServerURL());
|
||||
setIsValid(true);
|
||||
setCountdown(null);
|
||||
setHealthError(null);
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setServerURL(getServerURL());
|
||||
setOriginalURL(getServerURL());
|
||||
setIsValid(true);
|
||||
setCountdown(null);
|
||||
setHealthError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerURLChange = (value: string) => {
|
||||
setServerURL(value);
|
||||
setIsValid(isValidURL(value));
|
||||
setHealthError(null);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
setServerURL(DEFAULT_URL);
|
||||
setIsValid(true);
|
||||
setHealthError(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
setIsCheckingHealth(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${formatURL(serverURL)}/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
setHealthError(null);
|
||||
|
||||
localStorage.setItem("serverURL", formatURL(serverURL));
|
||||
|
||||
let count = 3;
|
||||
setCountdown(count);
|
||||
setOpen(false);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
count--;
|
||||
if (count <= 0) {
|
||||
clearInterval(interval);
|
||||
window.location.reload();
|
||||
} else {
|
||||
setCountdown(count);
|
||||
}
|
||||
};
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setHealthError(err instanceof Error ? err.message : "Failed to connect to server");
|
||||
} finally {
|
||||
setIsCheckingHealth(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerURLChange = (value: string) => {
|
||||
setServerURL(value);
|
||||
setIsValid(isValidURL(value));
|
||||
setHealthError(null);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<IconButton size="lg" className="absolute top-2 right-2" title={"Server Configuration"}>
|
||||
<Icon icon="serverIcon" className="size-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
setServerURL(DEFAULT_URL);
|
||||
setIsValid(true);
|
||||
setHealthError(null);
|
||||
};
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Server Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave) return;
|
||||
|
||||
setIsCheckingHealth(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${formatURL(serverURL)}/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
setHealthError(null);
|
||||
|
||||
localStorage.setItem("serverURL", formatURL(serverURL));
|
||||
|
||||
let count = 3;
|
||||
setCountdown(count);
|
||||
setOpen(false);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
count--;
|
||||
if (count <= 0) {
|
||||
clearInterval(interval);
|
||||
window.location.reload();
|
||||
} else {
|
||||
setCountdown(count);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setHealthError(err instanceof Error ? err.message : "Failed to connect to server");
|
||||
} finally {
|
||||
setIsCheckingHealth(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<IconButton
|
||||
size="lg"
|
||||
className="absolute top-2 right-2"
|
||||
title={"Server Configuration"}
|
||||
>
|
||||
<Icon icon="serverIcon" className="size-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Server Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="server-url">Server URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="server-url"
|
||||
value={serverURL}
|
||||
onChange={(e) => handleServerURLChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && canSave && !isCheckingHealth) {
|
||||
e.preventDefault();
|
||||
void handleSave();
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className={!isValid ? "border-destructive" : ""}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<IconButton
|
||||
variant={canSave ? "primary" : "outline"}
|
||||
size="md"
|
||||
disabled={!canSave || isCheckingHealth}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Icon icon="checkIcon" className="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="md"
|
||||
disabled={!isNotDefault || isCheckingHealth}
|
||||
onClick={handleResetToDefault}
|
||||
title="Reset to default"
|
||||
>
|
||||
<Icon icon="undo2" className="size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{!isValid && (
|
||||
<Label className="text-destructive text-sm">Please enter a valid URL</Label>
|
||||
)}
|
||||
{healthError && <Label className="text-destructive text-sm">{healthError}</Label>}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{countdown !== null &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 z-[100] bg-black/50 flex flex-col items-center justify-center pointer-events-auto">
|
||||
<div className="text-2xl font-bold pointer-events-none noselect">Redirecting</div>
|
||||
<div className="text-8xl font-bold pointer-events-none noselect">{countdown}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="server-url">Server URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="server-url"
|
||||
value={serverURL}
|
||||
onChange={(e) => handleServerURLChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && canSave && !isCheckingHealth) {
|
||||
e.preventDefault();
|
||||
void handleSave();
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className={!isValid ? "border-destructive" : ""}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<IconButton
|
||||
variant={canSave ? "primary" : "outline"}
|
||||
size="md"
|
||||
disabled={!canSave || isCheckingHealth}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Icon icon="checkIcon" className="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="md"
|
||||
disabled={!isNotDefault || isCheckingHealth}
|
||||
onClick={handleResetToDefault}
|
||||
title="Reset to default"
|
||||
>
|
||||
<Icon icon="undo2" className="size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{!isValid && <Label className="text-destructive text-sm">Please enter a valid URL</Label>}
|
||||
{healthError && <Label className="text-destructive text-sm">{healthError}</Label>}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{countdown !== null &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 z-[100] bg-black/50 flex flex-col items-center justify-center pointer-events-auto">
|
||||
<div className="text-2xl font-bold pointer-events-none noselect">Redirecting</div>
|
||||
<div className="text-8xl font-bold pointer-events-none noselect">{countdown}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,85 +5,85 @@ import Loading from "@/components/loading";
|
||||
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
|
||||
|
||||
interface SessionContextValue {
|
||||
user: UserRecord | null;
|
||||
setUser: (user: UserRecord) => void;
|
||||
isLoading: boolean;
|
||||
user: UserRecord | null;
|
||||
setUser: (user: UserRecord) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
// for use outside RequireAuth
|
||||
export function useSession(): SessionContextValue {
|
||||
const context = useContext(SessionContext);
|
||||
if (!context) {
|
||||
throw new Error("useSession must be used within a SessionProvider");
|
||||
}
|
||||
return context;
|
||||
const context = useContext(SessionContext);
|
||||
if (!context) {
|
||||
throw new Error("useSession must be used within a SessionProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// safe version that returns null if outside provider
|
||||
export function useSessionSafe(): SessionContextValue | null {
|
||||
return useContext(SessionContext);
|
||||
return useContext(SessionContext);
|
||||
}
|
||||
|
||||
// for use inside RequireAuth
|
||||
export function useAuthenticatedSession(): { user: UserRecord; setUser: (user: UserRecord) => void } {
|
||||
const { user, setUser } = useSession();
|
||||
if (!user) {
|
||||
throw new Error("useAuthenticatedSession must be used within RequireAuth");
|
||||
}
|
||||
return { user, setUser };
|
||||
const { user, setUser } = useSession();
|
||||
if (!user) {
|
||||
throw new Error("useAuthenticatedSession must be used within RequireAuth");
|
||||
}
|
||||
return { user, setUser };
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUserState] = useState<UserRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const fetched = useRef(false);
|
||||
const [user, setUserState] = useState<UserRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const fetched = useRef(false);
|
||||
|
||||
const setUser = useCallback((user: UserRecord) => {
|
||||
setUserState(user);
|
||||
localStorage.setItem("user", JSON.stringify(user));
|
||||
}, []);
|
||||
const setUser = useCallback((user: UserRecord) => {
|
||||
setUserState(user);
|
||||
localStorage.setItem("user", JSON.stringify(user));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
|
||||
fetch(`${getServerURL()}/auth/me`, {
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`auth check failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { user: UserRecord; csrfToken: string };
|
||||
setUser(data.user);
|
||||
setCsrfToken(data.csrfToken);
|
||||
})
|
||||
.catch(() => {
|
||||
setUserState(null);
|
||||
clearAuth();
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [setUser]);
|
||||
fetch(`${getServerURL()}/auth/me`, {
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`auth check failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { user: UserRecord; csrfToken: string };
|
||||
setUser(data.user);
|
||||
setCsrfToken(data.csrfToken);
|
||||
})
|
||||
.catch(() => {
|
||||
setUserState(null);
|
||||
clearAuth();
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [setUser]);
|
||||
|
||||
return <SessionContext.Provider value={{ user, setUser, isLoading }}>{children}</SessionContext.Provider>;
|
||||
return <SessionContext.Provider value={{ user, setUser, isLoading }}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { user, isLoading } = useSession();
|
||||
const location = useLocation();
|
||||
const { user, isLoading } = useSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading message={"Checking authentication"} />;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <Loading message={"Checking authentication"} />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const next = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?next=${next}`} replace />;
|
||||
}
|
||||
if (!user) {
|
||||
const next = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?next=${next}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,24 @@ import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
|
||||
import { cn, DARK_TEXT_COLOUR, isLight } from "@/lib/utils";
|
||||
|
||||
export default function SmallSprintDisplay({
|
||||
sprint,
|
||||
className,
|
||||
sprint,
|
||||
className,
|
||||
}: {
|
||||
sprint?: SprintRecord;
|
||||
className?: string;
|
||||
sprint?: SprintRecord;
|
||||
className?: string;
|
||||
}) {
|
||||
const colour = sprint?.color || DEFAULT_SPRINT_COLOUR;
|
||||
const textColour = isLight(colour) ? DARK_TEXT_COLOUR : "var(--foreground)";
|
||||
const colour = sprint?.color || DEFAULT_SPRINT_COLOUR;
|
||||
const textColour = isLight(colour) ? DARK_TEXT_COLOUR : "var(--foreground)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs px-1.5 rounded-full inline-flex whitespace-nowrap border border-foreground/10",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: colour, color: textColour }}
|
||||
>
|
||||
{sprint?.name || "None"}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs px-1.5 rounded-full inline-flex whitespace-nowrap border border-foreground/10",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: colour, color: textColour }}
|
||||
>
|
||||
{sprint?.name || "None"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ import Avatar from "@/components/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function SmallUserDisplay({ user, className }: { user: UserRecord; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex gap-2 items-center", className)}>
|
||||
<Avatar
|
||||
name={user.name}
|
||||
username={user.username}
|
||||
avatarURL={user.avatarURL}
|
||||
size={6}
|
||||
textClass="text-xs"
|
||||
/>
|
||||
{user.name}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex gap-2 items-center", className)}>
|
||||
<Avatar
|
||||
name={user.name}
|
||||
username={user.username}
|
||||
avatarURL={user.avatarURL}
|
||||
size={6}
|
||||
textClass="text-xs"
|
||||
/>
|
||||
{user.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import ColourPicker from "@/components/ui/colour-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field } from "@/components/ui/field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -23,317 +23,316 @@ import { cn } from "@/lib/utils";
|
||||
const SPRINT_NAME_MAX_LENGTH = 64;
|
||||
|
||||
const getStartOfDay = (date: Date) => {
|
||||
const next = new Date(date);
|
||||
next.setHours(0, 0, 0, 0);
|
||||
return next;
|
||||
const next = new Date(date);
|
||||
next.setHours(0, 0, 0, 0);
|
||||
return next;
|
||||
};
|
||||
|
||||
const getEndOfDay = (date: Date) => {
|
||||
const next = new Date(date);
|
||||
next.setHours(23, 59, 0, 0);
|
||||
return next;
|
||||
const next = new Date(date);
|
||||
next.setHours(23, 59, 0, 0);
|
||||
return next;
|
||||
};
|
||||
|
||||
const addDays = (date: Date, days: number) => {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
};
|
||||
|
||||
const getDefaultDates = () => {
|
||||
const today = new Date();
|
||||
return {
|
||||
start: getStartOfDay(today),
|
||||
end: getEndOfDay(addDays(today, 14)),
|
||||
};
|
||||
const today = new Date();
|
||||
return {
|
||||
start: getStartOfDay(today),
|
||||
end: getEndOfDay(addDays(today, 14)),
|
||||
};
|
||||
};
|
||||
|
||||
export function SprintForm({
|
||||
projectId,
|
||||
sprints,
|
||||
trigger,
|
||||
completeAction,
|
||||
mode = "create",
|
||||
existingSprint,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
projectId,
|
||||
sprints,
|
||||
trigger,
|
||||
completeAction,
|
||||
mode = "create",
|
||||
existingSprint,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: {
|
||||
projectId?: number;
|
||||
sprints: SprintRecord[];
|
||||
trigger?: React.ReactNode;
|
||||
completeAction?: (sprint: SprintRecord) => void | Promise<void>;
|
||||
mode?: "create" | "edit";
|
||||
existingSprint?: SprintRecord;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
projectId?: number;
|
||||
sprints: SprintRecord[];
|
||||
trigger?: React.ReactNode;
|
||||
completeAction?: (sprint: SprintRecord) => void | Promise<void>;
|
||||
mode?: "create" | "edit";
|
||||
existingSprint?: SprintRecord;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const { user } = useAuthenticatedSession();
|
||||
const createSprint = useCreateSprint();
|
||||
const updateSprint = useUpdateSprint();
|
||||
const { user } = useAuthenticatedSession();
|
||||
const createSprint = useCreateSprint();
|
||||
const updateSprint = useUpdateSprint();
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
|
||||
|
||||
const { start, end } = getDefaultDates();
|
||||
const [name, setName] = useState("");
|
||||
const [colour, setColour] = useState(DEFAULT_SPRINT_COLOUR);
|
||||
const [startDate, setStartDate] = useState(start);
|
||||
const [endDate, setEndDate] = useState(end);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { start, end } = getDefaultDates();
|
||||
const [name, setName] = useState("");
|
||||
const [colour, setColour] = useState(DEFAULT_SPRINT_COLOUR);
|
||||
const [startDate, setStartDate] = useState(start);
|
||||
const [endDate, setEndDate] = useState(end);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = mode === "edit";
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && existingSprint && open) {
|
||||
setName(existingSprint.name);
|
||||
setColour(existingSprint.color);
|
||||
setStartDate(new Date(existingSprint.startDate));
|
||||
setEndDate(new Date(existingSprint.endDate));
|
||||
}
|
||||
}, [isEdit, existingSprint, open]);
|
||||
useEffect(() => {
|
||||
if (isEdit && existingSprint && open) {
|
||||
setName(existingSprint.name);
|
||||
setColour(existingSprint.color);
|
||||
setStartDate(new Date(existingSprint.startDate));
|
||||
setEndDate(new Date(existingSprint.endDate));
|
||||
}
|
||||
}, [isEdit, existingSprint, open]);
|
||||
|
||||
const dateError = useMemo(() => {
|
||||
if (!submitAttempted) return "";
|
||||
if (startDate > endDate) {
|
||||
return "End date must be after start date";
|
||||
}
|
||||
return "";
|
||||
}, [endDate, startDate, submitAttempted]);
|
||||
const dateError = useMemo(() => {
|
||||
if (!submitAttempted) return "";
|
||||
if (startDate > endDate) {
|
||||
return "End date must be after start date";
|
||||
}
|
||||
return "";
|
||||
}, [endDate, startDate, submitAttempted]);
|
||||
|
||||
const reset = () => {
|
||||
const defaults = getDefaultDates();
|
||||
setName("");
|
||||
setColour(DEFAULT_SPRINT_COLOUR);
|
||||
setStartDate(defaults.start);
|
||||
setEndDate(defaults.end);
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
const reset = () => {
|
||||
const defaults = getDefaultDates();
|
||||
setName("");
|
||||
setColour(DEFAULT_SPRINT_COLOUR);
|
||||
setStartDate(defaults.start);
|
||||
setEndDate(defaults.end);
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitAttempted(true);
|
||||
|
||||
if (name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startDate > endDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.id) {
|
||||
setError(`you must be logged in to ${isEdit ? "edit" : "create"} a sprint`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEdit && !projectId) {
|
||||
setError("select a project first");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isEdit && existingSprint) {
|
||||
const data = await updateSprint.mutateAsync({
|
||||
id: existingSprint.id,
|
||||
name,
|
||||
color: colour,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success("Sprint updated");
|
||||
try {
|
||||
await completeAction?.(data);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
} else {
|
||||
if (!projectId) {
|
||||
setError("select a project first");
|
||||
return;
|
||||
}
|
||||
const data = await createSprint.mutateAsync({
|
||||
projectId,
|
||||
name,
|
||||
color: colour,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success(
|
||||
<>
|
||||
Created sprint <span style={{ color: data.color }}>{data.name}</span>
|
||||
</>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await completeAction?.(data);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
}
|
||||
} catch (submitError) {
|
||||
const message = parseError(submitError as Error);
|
||||
console.error(submitError);
|
||||
setError(message || `failed to ${isEdit ? "update" : "create"} sprint`);
|
||||
setSubmitting(false);
|
||||
toast.error(`Error ${isEdit ? "updating" : "creating"} sprint: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// filter out current sprint from the calendar display when editing
|
||||
const calendarSprints =
|
||||
isEdit && existingSprint ? sprints.filter((s) => s.id !== existingSprint.id) : sprints;
|
||||
|
||||
const dialogContent = (
|
||||
<DialogContent className={cn("w-md", (error || dateError) && "border-destructive")}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Sprint" : "Create Sprint"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-2">
|
||||
<Field
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
validate={(value) =>
|
||||
value.trim() === ""
|
||||
? "Cannot be empty"
|
||||
: value.trim().length > SPRINT_NAME_MAX_LENGTH
|
||||
? `Too long (${SPRINT_NAME_MAX_LENGTH} character limit)`
|
||||
: undefined
|
||||
}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Sprint 1"
|
||||
maxLength={SPRINT_NAME_MAX_LENGTH}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Start Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="justify-start">
|
||||
{startDate.toLocaleDateString()}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="center">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={(value) => {
|
||||
if (!value) return;
|
||||
setStartDate(getStartOfDay(value));
|
||||
}}
|
||||
autoFocus
|
||||
sprints={calendarSprints}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>End Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="justify-start">
|
||||
{endDate.toLocaleDateString()}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="center">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={endDate}
|
||||
onSelect={(value) => {
|
||||
if (!value) return;
|
||||
setEndDate(getEndOfDay(value));
|
||||
}}
|
||||
autoFocus
|
||||
sprints={calendarSprints}
|
||||
isEnd
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Colour</Label>
|
||||
<ColourPicker colour={colour} onChange={setColour} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||
{error || dateError ? (
|
||||
<Label className="text-destructive text-sm">{error ?? dateError}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) &&
|
||||
submitAttempted) ||
|
||||
(dateError !== "" && submitAttempted)
|
||||
}
|
||||
>
|
||||
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
if (isControlled) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
if (name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startDate > endDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.id) {
|
||||
setError(`you must be logged in to ${isEdit ? "edit" : "create"} a sprint`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEdit && !projectId) {
|
||||
setError("select a project first");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isEdit && existingSprint) {
|
||||
const data = await updateSprint.mutateAsync({
|
||||
id: existingSprint.id,
|
||||
name,
|
||||
color: colour,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success("Sprint updated");
|
||||
try {
|
||||
await completeAction?.(data);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
} else {
|
||||
if (!projectId) {
|
||||
setError("select a project first");
|
||||
return;
|
||||
}
|
||||
const data = await createSprint.mutateAsync({
|
||||
projectId,
|
||||
name,
|
||||
color: colour,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
toast.success(
|
||||
<>
|
||||
Created sprint <span style={{ color: data.color }}>{data.name}</span>
|
||||
</>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await completeAction?.(data);
|
||||
} catch (actionErr) {
|
||||
console.error(actionErr);
|
||||
}
|
||||
}
|
||||
} catch (submitError) {
|
||||
const message = parseError(submitError as Error);
|
||||
console.error(submitError);
|
||||
setError(message || `failed to ${isEdit ? "update" : "create"} sprint`);
|
||||
setSubmitting(false);
|
||||
toast.error(`Error ${isEdit ? "updating" : "creating"} sprint: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// filter out current sprint from the calendar display when editing
|
||||
const calendarSprints =
|
||||
isEdit && existingSprint ? sprints.filter((s) => s.id !== existingSprint.id) : sprints;
|
||||
|
||||
const dialogContent = (
|
||||
<DialogContent className={cn("w-md", (error || dateError) && "border-destructive")}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Sprint" : "Create Sprint"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-2">
|
||||
<Field
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
validate={(value) =>
|
||||
value.trim() === ""
|
||||
? "Cannot be empty"
|
||||
: value.trim().length > SPRINT_NAME_MAX_LENGTH
|
||||
? `Too long (${SPRINT_NAME_MAX_LENGTH} character limit)`
|
||||
: undefined
|
||||
}
|
||||
submitAttempted={submitAttempted}
|
||||
placeholder="Sprint 1"
|
||||
maxLength={SPRINT_NAME_MAX_LENGTH}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Start Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="justify-start">
|
||||
{startDate.toLocaleDateString()}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="center">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={(value) => {
|
||||
if (!value) return;
|
||||
setStartDate(getStartOfDay(value));
|
||||
}}
|
||||
autoFocus
|
||||
sprints={calendarSprints}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>End Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="justify-start">
|
||||
{endDate.toLocaleDateString()}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="center">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={endDate}
|
||||
onSelect={(value) => {
|
||||
if (!value) return;
|
||||
setEndDate(getEndOfDay(value));
|
||||
}}
|
||||
autoFocus
|
||||
sprints={calendarSprints}
|
||||
isEnd
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Colour</Label>
|
||||
<ColourPicker colour={colour} onChange={setColour} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
|
||||
{error || dateError ? (
|
||||
<Label className="text-destructive text-sm">{error ?? dateError}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full justify-end mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) && submitAttempted) ||
|
||||
(dateError !== "" && submitAttempted)
|
||||
}
|
||||
>
|
||||
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
if (isControlled) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" disabled={!projectId}>
|
||||
Create Sprint
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" disabled={!projectId}>
|
||||
Create Sprint
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
{dialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,43 +4,43 @@ import SmallSprintDisplay from "@/components/small-sprint-display";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export function SprintSelect({
|
||||
sprints,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select sprint",
|
||||
sprints,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select sprint",
|
||||
}: {
|
||||
sprints: SprintRecord[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
fallbackUser?: UserRecord | null;
|
||||
placeholder?: string;
|
||||
sprints: SprintRecord[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
fallbackUser?: UserRecord | null;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
|
||||
<SelectTrigger
|
||||
className="group w-auto flex items-center -mt-1"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} className="hover:opacity-85" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
side="bottom"
|
||||
position="popper"
|
||||
className="data-[side=bottom]:translate-y-1 data-[side=bottom]:translate-x-1"
|
||||
>
|
||||
<SelectItem value="unassigned">
|
||||
<SmallSprintDisplay />
|
||||
</SelectItem>
|
||||
{sprints.map((sprint) => (
|
||||
<SelectItem key={sprint.id} value={sprint.id.toString()}>
|
||||
<SmallSprintDisplay sprint={sprint} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
|
||||
<SelectTrigger
|
||||
className="group w-auto flex items-center -mt-1"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} className="hover:opacity-85" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
side="bottom"
|
||||
position="popper"
|
||||
className="data-[side=bottom]:translate-y-1 data-[side=bottom]:translate-x-1"
|
||||
>
|
||||
<SelectItem value="unassigned">
|
||||
<SmallSprintDisplay />
|
||||
</SelectItem>
|
||||
{sprints.map((sprint) => (
|
||||
<SelectItem key={sprint.id} value={sprint.id.toString()}>
|
||||
<SmallSprintDisplay sprint={sprint} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,41 +4,41 @@ import StatusTag from "@/components/status-tag";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export function StatusSelect({
|
||||
statuses,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select status",
|
||||
trigger,
|
||||
statuses,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select status",
|
||||
trigger,
|
||||
}: {
|
||||
statuses: Record<string, string>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
trigger?: (args: { isOpen: boolean; value: string }) => ReactNode;
|
||||
statuses: Record<string, string>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
trigger?: (args: { isOpen: boolean; value: string }) => ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
|
||||
{trigger ? (
|
||||
trigger({ isOpen, value })
|
||||
) : (
|
||||
<SelectTrigger
|
||||
className="w-fit px-2 text-xs gap-1"
|
||||
size="sm"
|
||||
chevronClassName={"size-3 -mr-1"}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
)}
|
||||
<SelectContent side="bottom" position="popper" align="start">
|
||||
{Object.entries(statuses).map(([status, colour]) => (
|
||||
<SelectItem key={status} value={status} textClassName="text-xs">
|
||||
<StatusTag status={status} colour={colour} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
|
||||
{trigger ? (
|
||||
trigger({ isOpen, value })
|
||||
) : (
|
||||
<SelectTrigger
|
||||
className="w-fit px-2 text-xs gap-1"
|
||||
size="sm"
|
||||
chevronClassName={"size-3 -mr-1"}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
)}
|
||||
<SelectContent side="bottom" position="popper" align="start">
|
||||
{Object.entries(statuses).map(([status, colour]) => (
|
||||
<SelectItem key={status} value={status} textClassName="text-xs">
|
||||
<StatusTag status={status} colour={colour} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,25 @@ import { DEFAULT_STATUS_COLOUR } from "@sprint/shared";
|
||||
import { cn, DARK_TEXT_COLOUR, isLight } from "@/lib/utils";
|
||||
|
||||
export default function StatusTag({
|
||||
status,
|
||||
colour = DEFAULT_STATUS_COLOUR,
|
||||
className,
|
||||
status,
|
||||
colour = DEFAULT_STATUS_COLOUR,
|
||||
className,
|
||||
}: {
|
||||
status: string;
|
||||
colour: string;
|
||||
className?: string;
|
||||
status: string;
|
||||
colour: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const textColour = isLight(colour) ? DARK_TEXT_COLOUR : "var(--foreground)";
|
||||
const textColour = isLight(colour) ? DARK_TEXT_COLOUR : "var(--foreground)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs px-1 rounded inline-flex whitespace-nowrap border border-foreground/10",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: colour, color: textColour }}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs px-1 rounded inline-flex whitespace-nowrap border border-foreground/10",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: colour, color: textColour }}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,67 +3,67 @@ import { createContext, useContext, useEffect, useState } from "react";
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -4,28 +4,28 @@ import { IconButton } from "@/components/ui/icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ThemeToggle({ withText, className }: { withText?: boolean; className?: string }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const resolvedTheme =
|
||||
theme === "system"
|
||||
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light"
|
||||
: theme;
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const { theme, setTheme } = useTheme();
|
||||
const resolvedTheme =
|
||||
theme === "system"
|
||||
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light"
|
||||
: theme;
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<IconButton
|
||||
size="md"
|
||||
className={cn("hover:text-muted-foreground", className)}
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{isDark ? <Icon icon="sun" className="size-5" /> : <Icon icon="moon" className="size-5" />}
|
||||
</IconButton>
|
||||
{withText && (isDark ? "Dark Mode" : "Light Mode")}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<IconButton
|
||||
size="md"
|
||||
className={cn("hover:text-muted-foreground", className)}
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{isDark ? <Icon icon="sun" className="size-5" /> : <Icon icon="moon" className="size-5" />}
|
||||
</IconButton>
|
||||
{withText && (isDark ? "Dark Mode" : "Light Mode")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeToggle;
|
||||
|
||||
@@ -9,59 +9,59 @@ const FALLBACK_TIME = "--:--:--";
|
||||
const REFRESH_INTERVAL_MS = 10000;
|
||||
|
||||
export function getWorkTimeMs(timestamps: string[] | undefined): number {
|
||||
if (!timestamps?.length) return 0;
|
||||
const dates = timestamps.map((t) => new Date(t));
|
||||
return calculateWorkTimeMs(dates);
|
||||
if (!timestamps?.length) return 0;
|
||||
const dates = timestamps.map((t) => new Date(t));
|
||||
return calculateWorkTimeMs(dates);
|
||||
}
|
||||
|
||||
export function TimerDisplay({ issueId }: { issueId: number }) {
|
||||
const { data: timerState, error: timerError } = useTimerState(issueId, {
|
||||
refetchInterval: REFRESH_INTERVAL_MS,
|
||||
});
|
||||
const { data: inactiveTimers = [], error: inactiveError } = useInactiveTimers(issueId, {
|
||||
refetchInterval: REFRESH_INTERVAL_MS,
|
||||
});
|
||||
const { data: timerState, error: timerError } = useTimerState(issueId, {
|
||||
refetchInterval: REFRESH_INTERVAL_MS,
|
||||
});
|
||||
const { data: inactiveTimers = [], error: inactiveError } = useInactiveTimers(issueId, {
|
||||
refetchInterval: REFRESH_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const combinedError = timerError ?? inactiveError;
|
||||
const combinedError = timerError ?? inactiveError;
|
||||
|
||||
useEffect(() => {
|
||||
if (combinedError) {
|
||||
const message = parseError(combinedError as Error);
|
||||
setError(message);
|
||||
toast.error(`Error fetching timer data: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
}, [combinedError]);
|
||||
useEffect(() => {
|
||||
if (combinedError) {
|
||||
const message = parseError(combinedError as Error);
|
||||
setError(message);
|
||||
toast.error(`Error fetching timer data: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
}, [combinedError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timerState?.isRunning) return;
|
||||
useEffect(() => {
|
||||
if (!timerState?.isRunning) return;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setTick((t) => t + 1);
|
||||
}, 1000);
|
||||
const interval = window.setInterval(() => {
|
||||
setTick((t) => t + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [timerState?.isRunning]);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [timerState?.isRunning]);
|
||||
|
||||
const inactiveWorkTimeMs = useMemo(
|
||||
() => inactiveTimers.reduce((total, session) => total + getWorkTimeMs(session?.timestamps), 0),
|
||||
[inactiveTimers],
|
||||
);
|
||||
const inactiveWorkTimeMs = useMemo(
|
||||
() => inactiveTimers.reduce((total, session) => total + getWorkTimeMs(session?.timestamps), 0),
|
||||
[inactiveTimers],
|
||||
);
|
||||
|
||||
void tick;
|
||||
const currentWorkTimeMs = getWorkTimeMs(timerState?.timestamps);
|
||||
const totalWorkTimeMs = inactiveWorkTimeMs + currentWorkTimeMs;
|
||||
const displayWorkTime = error ? FALLBACK_TIME : formatTime(totalWorkTimeMs);
|
||||
void tick;
|
||||
const currentWorkTimeMs = getWorkTimeMs(timerState?.timestamps);
|
||||
const totalWorkTimeMs = inactiveWorkTimeMs + currentWorkTimeMs;
|
||||
const displayWorkTime = error ? FALLBACK_TIME : formatTime(totalWorkTimeMs);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono tabular-nums">{displayWorkTime}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono tabular-nums">{displayWorkTime}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,19 +5,19 @@ import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import Icon from "@/components/ui/icon";
|
||||
|
||||
export function TimerModal({ issueId, disabled }: { issueId: number; disabled?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild disabled={disabled}>
|
||||
<Button variant="outline" size="sm" disabled={disabled}>
|
||||
<Icon icon="timer" className="size-4" />
|
||||
Timer
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-xs" showCloseButton={false}>
|
||||
<IssueTimer issueId={issueId} onEnd={() => setOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild disabled={disabled}>
|
||||
<Button variant="outline" size="sm" disabled={disabled}>
|
||||
<Icon icon="timer" className="size-4" />
|
||||
Timer
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-xs" showCloseButton={false}>
|
||||
<IssueTimer issueId={issueId} onEnd={() => setOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
@@ -24,79 +24,79 @@ import { BREATHING_ROOM } from "@/lib/layout";
|
||||
import { useOrganisations } from "@/lib/query/hooks";
|
||||
|
||||
export default function TopBar({ showIssueForm = true }: { showIssueForm?: boolean }) {
|
||||
const { user } = useAuthenticatedSession();
|
||||
const { selectedOrganisationId, selectedProjectId } = useSelection();
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
const { user } = useAuthenticatedSession();
|
||||
const { selectedOrganisationId, selectedProjectId } = useSelection();
|
||||
const { data: organisationsData = [] } = useOrganisations();
|
||||
|
||||
const selectedOrganisation = useMemo(
|
||||
() => organisationsData.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
|
||||
[organisationsData, selectedOrganisationId],
|
||||
);
|
||||
const selectedOrganisation = useMemo(
|
||||
() => organisationsData.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
|
||||
[organisationsData, selectedOrganisationId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex gap-12 items-center justify-between">
|
||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||
<OrganisationSelect
|
||||
noDecoration
|
||||
triggerClassName="px-1 rounded-full hover:bg-transparent dark:hover:bg-transparent"
|
||||
trigger={
|
||||
<OrgIcon
|
||||
name={selectedOrganisation?.Organisation.name ?? ""}
|
||||
slug={selectedOrganisation?.Organisation.slug ?? ""}
|
||||
iconURL={selectedOrganisation?.Organisation.iconURL || undefined}
|
||||
size={7}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
return (
|
||||
<div className="flex gap-12 items-center justify-between">
|
||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||
<OrganisationSelect
|
||||
noDecoration
|
||||
triggerClassName="px-1 rounded-full hover:bg-transparent dark:hover:bg-transparent"
|
||||
trigger={
|
||||
<OrgIcon
|
||||
name={selectedOrganisation?.Organisation.name ?? ""}
|
||||
slug={selectedOrganisation?.Organisation.slug ?? ""}
|
||||
iconURL={selectedOrganisation?.Organisation.iconURL || undefined}
|
||||
size={7}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedOrganisationId && <ProjectSelect showLabel />}
|
||||
{selectedOrganisationId && selectedProjectId && showIssueForm && (
|
||||
<IssueForm
|
||||
trigger={
|
||||
<IconButton
|
||||
variant="outline"
|
||||
className="w-9 h-9"
|
||||
title="Create Issue"
|
||||
aria-label="Create issue"
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-sm">
|
||||
<SmallUserDisplay user={user} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||
<Account />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||
<Organisations />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||
<ServerConfiguration
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex w-full 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>
|
||||
);
|
||||
{selectedOrganisationId && <ProjectSelect showLabel />}
|
||||
{selectedOrganisationId && selectedProjectId && showIssueForm && (
|
||||
<IssueForm
|
||||
trigger={
|
||||
<IconButton
|
||||
variant="outline"
|
||||
className="w-9 h-9"
|
||||
title="Create Issue"
|
||||
aria-label="Create issue"
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-sm">
|
||||
<SmallUserDisplay user={user} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||
<Account />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||
<Organisations />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||
<ServerConfiguration
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex w-full 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,73 +5,73 @@ import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline: "bg-transparent border dark:hover:bg-muted/40",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
dummy: "",
|
||||
},
|
||||
size: {
|
||||
none: "",
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline: "bg-transparent border dark:hover:bg-muted/40",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
dummy: "",
|
||||
},
|
||||
size: {
|
||||
none: "",
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
linkTo,
|
||||
...props
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
linkTo,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
linkTo?: string;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
linkTo?: string;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkTo ? (
|
||||
<Link to={linkTo}>
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{linkTo ? (
|
||||
<Link to={linkTo}>
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -6,213 +6,202 @@ import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
sprints,
|
||||
isEnd,
|
||||
...props
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
sprints,
|
||||
isEnd,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
sprints?: SprintRecord[];
|
||||
isEnd?: boolean;
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
sprints?: SprintRecord[];
|
||||
isEnd?: boolean;
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px]",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center group/day aspect-square select-none",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn("bg-accent", defaultClassNames.range_start),
|
||||
range_middle: cn(defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent", defaultClassNames.range_end),
|
||||
today: cn("border border-dashed -m-px", defaultClassNames.today),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return <Icon icon="chevronLeftIcon" className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px]",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
|
||||
week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center group/day aspect-square select-none",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn("bg-accent", defaultClassNames.range_start),
|
||||
range_middle: cn(defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent", defaultClassNames.range_end),
|
||||
today: cn("border border-dashed -m-px", defaultClassNames.today),
|
||||
outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside),
|
||||
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return <Icon icon="chevronLeftIcon" className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<Icon icon="chevronRightIcon" className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
}
|
||||
if (orientation === "right") {
|
||||
return <Icon icon="chevronRightIcon" className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
return <Icon icon="chevronDownIcon" className={cn("size-4", className)} {...props} />;
|
||||
},
|
||||
DayButton: (props) => <CalendarDayButton {...props} sprints={sprints} isEnd={isEnd} />,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <Icon icon="chevronDownIcon" className={cn("size-4", className)} {...props} />;
|
||||
},
|
||||
DayButton: (props) => <CalendarDayButton {...props} sprints={sprints} isEnd={isEnd} />,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
sprints,
|
||||
style,
|
||||
disabled,
|
||||
isEnd,
|
||||
...props
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
sprints,
|
||||
style,
|
||||
disabled,
|
||||
isEnd,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton> & { sprints?: SprintRecord[]; isEnd?: boolean }) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
let isDisabled = false;
|
||||
let sprint: SprintRecord | null = null;
|
||||
let isDisabled = false;
|
||||
let sprint: SprintRecord | null = null;
|
||||
|
||||
for (const entry of sprints || []) {
|
||||
if (day.date >= new Date(entry.startDate) && day.date <= new Date(entry.endDate)) {
|
||||
isDisabled = true;
|
||||
sprint = entry;
|
||||
}
|
||||
for (const entry of sprints || []) {
|
||||
if (day.date >= new Date(entry.startDate) && day.date <= new Date(entry.endDate)) {
|
||||
isDisabled = true;
|
||||
sprint = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal",
|
||||
"[&>span]:text-xs [&>span]:opacity-70",
|
||||
!sprint?.color && "hover:bg-primary/90 hover:text-foreground",
|
||||
"data-[selected-single=true]:!bg-foreground data-[selected-single=true]:!text-background data-[selected-single=true]:hover:!bg-foreground/90",
|
||||
"data-[range-start=true]:!bg-foreground data-[range-start=true]:!text-background",
|
||||
"data-[range-middle=true]:!bg-foreground data-[range-middle=true]:!text-background",
|
||||
"data-[range-end=true]:!bg-foreground data-[range-end=true]:!text-background",
|
||||
sprint?.color && "border-t border-b !border-(--sprint-color) !bg-(--sprint-color)/5",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--sprint-color": sprint?.color ? sprint.color : null,
|
||||
borderLeft:
|
||||
sprint && day.date.getUTCDate() === new Date(sprint.startDate).getUTCDate()
|
||||
? `1px solid ${sprint?.color}`
|
||||
: day.date.getDay() === 0 // sunday (left side)
|
||||
? `1px dashed ${sprint?.color}`
|
||||
: `0px`,
|
||||
borderRight:
|
||||
sprint && day.date.getUTCDate() === new Date(sprint.endDate).getUTCDate()
|
||||
? `1px solid ${sprint?.color}`
|
||||
: day.date.getDay() === 6 // saturday (right side)
|
||||
? `1px dashed ${sprint?.color}`
|
||||
: `0px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
disabled={isDisabled || disabled}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal",
|
||||
"[&>span]:text-xs [&>span]:opacity-70",
|
||||
!sprint?.color && "hover:bg-primary/90 hover:text-foreground",
|
||||
"data-[selected-single=true]:!bg-foreground data-[selected-single=true]:!text-background data-[selected-single=true]:hover:!bg-foreground/90",
|
||||
"data-[range-start=true]:!bg-foreground data-[range-start=true]:!text-background",
|
||||
"data-[range-middle=true]:!bg-foreground data-[range-middle=true]:!text-background",
|
||||
"data-[range-end=true]:!bg-foreground data-[range-end=true]:!text-background",
|
||||
sprint?.color && "border-t border-b !border-(--sprint-color) !bg-(--sprint-color)/5",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--sprint-color": sprint?.color ? sprint.color : null,
|
||||
borderLeft:
|
||||
sprint && day.date.getUTCDate() === new Date(sprint.startDate).getUTCDate()
|
||||
? `1px solid ${sprint?.color}`
|
||||
: day.date.getDay() === 0 // sunday (left side)
|
||||
? `1px dashed ${sprint?.color}`
|
||||
: `0px`,
|
||||
borderRight:
|
||||
sprint && day.date.getUTCDate() === new Date(sprint.endDate).getUTCDate()
|
||||
? `1px solid ${sprint?.color}`
|
||||
: day.date.getDay() === 6 // saturday (right side)
|
||||
? `1px dashed ${sprint?.color}`
|
||||
: `0px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
disabled={isDisabled || disabled}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
|
||||
@@ -5,39 +5,35 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ColourPicker({
|
||||
colour,
|
||||
onChange,
|
||||
asChild = true,
|
||||
className,
|
||||
colour,
|
||||
onChange,
|
||||
asChild = true,
|
||||
className,
|
||||
}: {
|
||||
colour: string;
|
||||
onChange: (value: string) => void;
|
||||
asChild?: boolean;
|
||||
className?: string;
|
||||
colour: string;
|
||||
onChange: (value: string) => void;
|
||||
asChild?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild={asChild}>
|
||||
<Button
|
||||
type="button"
|
||||
className={cn("w-8 h-8", className)}
|
||||
style={{ backgroundColor: colour }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit grid gap-2 p-2" align="start" side={"top"}>
|
||||
<HexColorPicker color={colour} onChange={onChange} className="p-0 m-0" />
|
||||
<div className="border w-[92px] inline-flex items-center">
|
||||
<Input
|
||||
value={colour.slice(1).toUpperCase()}
|
||||
onChange={(e) => onChange(`#${e.target.value}`)}
|
||||
spellCheck={false}
|
||||
className="flex-1 border-transparent h-fit pl-0 mx-0"
|
||||
maxLength={6}
|
||||
showCounter={false}
|
||||
showHashPrefix
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild={asChild}>
|
||||
<Button type="button" className={cn("w-8 h-8", className)} style={{ backgroundColor: colour }} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit grid gap-2 p-2" align="start" side={"top"}>
|
||||
<HexColorPicker color={colour} onChange={onChange} className="p-0 m-0" />
|
||||
<div className="border w-[92px] inline-flex items-center">
|
||||
<Input
|
||||
value={colour.slice(1).toUpperCase()}
|
||||
onChange={(e) => onChange(`#${e.target.value}`)}
|
||||
spellCheck={false}
|
||||
className="flex-1 border-transparent h-fit pl-0 mx-0"
|
||||
maxLength={6}
|
||||
showCounter={false}
|
||||
showHashPrefix
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,55 +3,55 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
processingText = "Processing...",
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
variant = "default",
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
processingText = "Processing...",
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
variant = "default",
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
processingText?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "default" | "destructive";
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
processingText?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const handleConfirm = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{cancelText}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant={variant} onClick={handleConfirm} disabled={submitting}>
|
||||
{submitting ? processingText : confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{cancelText}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant={variant} onClick={handleConfirm} disabled={submitting}>
|
||||
{submitting ? processingText : confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,133 +5,133 @@ import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn("fixed inset-0 z-50 bg-black/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn("fixed inset-0 z-50 bg-black/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
closePos = "top-right",
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
closePos = "top-right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
closePos?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
showCloseButton?: boolean;
|
||||
closePos?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%]",
|
||||
"z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||
"gap-4 border p-4 shadow-lg duration-200 outline-none w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className={cn(
|
||||
"cursor-pointer ring-offset-background focus:ring-ring",
|
||||
"data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
||||
"absolute opacity-70",
|
||||
closePos === "top-left" && "top-4 left-4",
|
||||
closePos === "top-right" && "top-4 right-4",
|
||||
closePos === "bottom-left" && "bottom-4 left-4",
|
||||
closePos === "bottom-right" && "bottom-4 right-4",
|
||||
"hover:opacity-100 focus:ring-2 focus:ring-offset-2 ",
|
||||
"ocus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
)}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%]",
|
||||
"z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||
"gap-4 border p-4 shadow-lg duration-200 outline-none w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className={cn(
|
||||
"cursor-pointer ring-offset-background focus:ring-ring",
|
||||
"data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
||||
"absolute opacity-70",
|
||||
closePos === "top-left" && "top-4 left-4",
|
||||
closePos === "top-right" && "top-4 right-4",
|
||||
closePos === "bottom-left" && "bottom-4 left-4",
|
||||
closePos === "bottom-right" && "bottom-4 right-4",
|
||||
"hover:opacity-100 focus:ring-2 focus:ring-offset-2 ",
|
||||
"ocus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
)}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -4,286 +4,286 @@ import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
noStyle = false,
|
||||
...props
|
||||
className,
|
||||
size = "default",
|
||||
noStyle = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
noStyle?: boolean;
|
||||
size?: "sm" | "default";
|
||||
noStyle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
data-size={size}
|
||||
className={
|
||||
noStyle
|
||||
? cn(className)
|
||||
: cn(
|
||||
"cursor-pointer border data-[placeholder]:text-muted-foreground",
|
||||
"[&_svg:not([class*='text-'])]:text-foreground",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
"aria-invalid:border-destructive dark:hover:bg-muted/40",
|
||||
"flex w-fit items-center justify-between gap-2 border",
|
||||
"bg-transparent px-3 py-2 text-sm whitespace-nowrap",
|
||||
"shadow-xs outline-none disabled:cursor-not-allowed",
|
||||
"disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex",
|
||||
"*:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
data-size={size}
|
||||
className={
|
||||
noStyle
|
||||
? cn(className)
|
||||
: cn(
|
||||
"cursor-pointer border data-[placeholder]:text-muted-foreground",
|
||||
"[&_svg:not([class*='text-'])]:text-foreground",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
"aria-invalid:border-destructive dark:hover:bg-muted/40",
|
||||
"flex w-fit items-center justify-between gap-2 border",
|
||||
"bg-transparent px-3 py-2 text-sm whitespace-nowrap",
|
||||
"shadow-xs outline-none disabled:cursor-not-allowed",
|
||||
"disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex",
|
||||
"*:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
...props
|
||||
className,
|
||||
sideOffset = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2",
|
||||
"data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2",
|
||||
"data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height)",
|
||||
"min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin)",
|
||||
"overflow-x-hidden overflow-y-auto border p-1 shadow-md",
|
||||
"data-[side=bottom]:translate-y-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2",
|
||||
"data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2",
|
||||
"data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height)",
|
||||
"min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin)",
|
||||
"overflow-x-hidden overflow-y-auto border p-1 shadow-md",
|
||||
"data-[side=bottom]:translate-y-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent/40 focus:text-accent-foreground",
|
||||
"data-[variant=destructive]:text-destructive",
|
||||
"data-[variant=destructive]:focus:bg-destructive/10",
|
||||
"dark:data-[variant=destructive]:focus:bg-destructive/20",
|
||||
"data-[variant=destructive]:focus:text-destructive",
|
||||
"data-[variant=destructive]:*:[svg]:!text-destructive",
|
||||
"[&_svg:not([class*='text-'])]:text-foreground relative",
|
||||
"flex w-full cursor-pointer items-center gap-2",
|
||||
"px-2 py-1 text-sm outline-hidden select-none",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"data-[inset]:pl-8 [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent/40 focus:text-accent-foreground",
|
||||
"data-[variant=destructive]:text-destructive",
|
||||
"data-[variant=destructive]:focus:bg-destructive/10",
|
||||
"dark:data-[variant=destructive]:focus:bg-destructive/20",
|
||||
"data-[variant=destructive]:focus:text-destructive",
|
||||
"data-[variant=destructive]:*:[svg]:!text-destructive",
|
||||
"[&_svg:not([class*='text-'])]:text-foreground relative",
|
||||
"flex w-full cursor-pointer items-center gap-2",
|
||||
"px-2 py-1 text-sm outline-hidden select-none",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"data-[inset]:pl-8 [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative",
|
||||
"flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8",
|
||||
"text-sm outline-hidden select-none data-[disabled]:pointer-events-none",
|
||||
"data-[disabled]:opacity-50 [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Icon icon="checkIcon" className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative",
|
||||
"flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8",
|
||||
"text-sm outline-hidden select-none data-[disabled]:pointer-events-none",
|
||||
"data-[disabled]:opacity-50 [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Icon icon="checkIcon" className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative",
|
||||
"flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8",
|
||||
"text-sm outline-hidden select-none data-[disabled]:pointer-events-none",
|
||||
"data-[disabled]:opacity-50 [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Icon icon="circleIcon" className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative",
|
||||
"flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8",
|
||||
"text-sm outline-hidden select-none data-[disabled]:pointer-events-none",
|
||||
"data-[disabled]:opacity-50 [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Icon icon="circleIcon" className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-0 text-sm font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-0 text-sm font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent",
|
||||
"data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-foreground",
|
||||
"flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icon icon="chevronRightIcon" className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent",
|
||||
"data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-foreground",
|
||||
"flex cursor-default items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icon icon="chevronRightIcon" className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2",
|
||||
"data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
|
||||
"data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem]",
|
||||
"origin-(--radix-dropdown-menu-content-transform-origin)",
|
||||
"overflow-hidden border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2",
|
||||
"data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
|
||||
"data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem]",
|
||||
"origin-(--radix-dropdown-menu-content-transform-origin)",
|
||||
"overflow-hidden border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
|
||||
@@ -3,73 +3,73 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
value = "",
|
||||
onChange = () => {},
|
||||
validate,
|
||||
hidden = false,
|
||||
submitAttempted,
|
||||
placeholder,
|
||||
error,
|
||||
tabIndex,
|
||||
spellcheck,
|
||||
maxLength,
|
||||
showCounter = true,
|
||||
label,
|
||||
value = "",
|
||||
onChange = () => {},
|
||||
validate,
|
||||
hidden = false,
|
||||
submitAttempted,
|
||||
placeholder,
|
||||
error,
|
||||
tabIndex,
|
||||
spellcheck,
|
||||
maxLength,
|
||||
showCounter = true,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
validate?: (value: string) => string | undefined;
|
||||
hidden?: boolean;
|
||||
submitAttempted?: boolean;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
tabIndex?: number;
|
||||
spellcheck?: boolean;
|
||||
maxLength?: number;
|
||||
showCounter?: boolean;
|
||||
label: string;
|
||||
value?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
validate?: (value: string) => string | undefined;
|
||||
hidden?: boolean;
|
||||
submitAttempted?: boolean;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
tabIndex?: number;
|
||||
spellcheck?: boolean;
|
||||
maxLength?: number;
|
||||
showCounter?: boolean;
|
||||
}) {
|
||||
const [internalTouched, setInternalTouched] = useState(false);
|
||||
const isTouched = submitAttempted || internalTouched;
|
||||
const [internalTouched, setInternalTouched] = useState(false);
|
||||
const isTouched = submitAttempted || internalTouched;
|
||||
|
||||
const invalidMessage = useMemo(() => {
|
||||
if (!isTouched && value === "") {
|
||||
return "";
|
||||
}
|
||||
return validate?.(value) ?? "";
|
||||
}, [isTouched, validate, value]);
|
||||
const invalidMessage = useMemo(() => {
|
||||
if (!isTouched && value === "") {
|
||||
return "";
|
||||
}
|
||||
return validate?.(value) ?? "";
|
||||
}, [isTouched, validate, value]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex items-end justify-between w-full">
|
||||
<Label htmlFor={label} className="flex items-center text-sm">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
id={label}
|
||||
placeholder={placeholder ?? label}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e);
|
||||
setInternalTouched(true);
|
||||
}}
|
||||
onBlur={() => setInternalTouched(true)}
|
||||
name={label}
|
||||
aria-invalid={error !== undefined || invalidMessage !== ""}
|
||||
type={hidden ? "password" : "text"}
|
||||
tabIndex={tabIndex}
|
||||
spellCheck={spellcheck}
|
||||
maxLength={maxLength}
|
||||
showCounter={showCounter}
|
||||
/>
|
||||
<div className="flex items-end justify-end w-full text-xs mb-0 -mt-1">
|
||||
{error || invalidMessage !== "" ? (
|
||||
<Label className="text-destructive text-sm">{error ?? invalidMessage}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex items-end justify-between w-full">
|
||||
<Label htmlFor={label} className="flex items-center text-sm">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
id={label}
|
||||
placeholder={placeholder ?? label}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e);
|
||||
setInternalTouched(true);
|
||||
}}
|
||||
onBlur={() => setInternalTouched(true)}
|
||||
name={label}
|
||||
aria-invalid={error !== undefined || invalidMessage !== ""}
|
||||
type={hidden ? "password" : "text"}
|
||||
tabIndex={tabIndex}
|
||||
spellCheck={spellcheck}
|
||||
maxLength={maxLength}
|
||||
showCounter={showCounter}
|
||||
/>
|
||||
<div className="flex items-end justify-end w-full text-xs mb-0 -mt-1">
|
||||
{error || invalidMessage !== "" ? (
|
||||
<Label className="text-destructive text-sm">{error ?? invalidMessage}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,42 +3,40 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const iconButtonVariants = cva(
|
||||
"cursor-pointer inline-flex items-center justify-center [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:text-foreground/70",
|
||||
destructive: "text-destructive hover:text-destructive/70",
|
||||
yellow: "text-yellow-500 hover:text-yellow-500/70",
|
||||
green: "text-green-500 hover:text-green-500/70",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
outline: "border bg-transparent dark:hover:bg-muted/40",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
},
|
||||
size: {
|
||||
default: "w-6 h-6",
|
||||
sm: "w-5 h-5",
|
||||
md: "w-9 h-9",
|
||||
lg: "w-10 h-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
"cursor-pointer inline-flex items-center justify-center [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:text-foreground/70",
|
||||
destructive: "text-destructive hover:text-destructive/70",
|
||||
yellow: "text-yellow-500 hover:text-yellow-500/70",
|
||||
green: "text-green-500 hover:text-green-500/70",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
outline: "border bg-transparent dark:hover:bg-muted/40",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
},
|
||||
size: {
|
||||
default: "w-6 h-6",
|
||||
sm: "w-5 h-5",
|
||||
md: "w-9 h-9",
|
||||
lg: "w-10 h-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function IconButton({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & VariantProps<typeof iconButtonVariants>) {
|
||||
return (
|
||||
<button type="button" className={cn(iconButtonVariants({ variant, size, className }))} {...props} />
|
||||
);
|
||||
return <button type="button" className={cn(iconButtonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { IconButton, iconButtonVariants };
|
||||
|
||||
@@ -1,143 +1,143 @@
|
||||
import {
|
||||
Alert as PixelAlert,
|
||||
Check as PixelCheck,
|
||||
ChevronDown as PixelChevronDown,
|
||||
ChevronLeft as PixelChevronLeft,
|
||||
ChevronRight as PixelChevronRight,
|
||||
ChevronUp as PixelChevronUp,
|
||||
Circle as PixelCircle,
|
||||
Clock as PixelClock,
|
||||
Close as PixelClose,
|
||||
Edit as PixelEdit,
|
||||
Home as PixelHome,
|
||||
InfoBox as PixelInfo,
|
||||
Link as PixelLink,
|
||||
Loader as PixelLoader,
|
||||
Logout as PixelLogout,
|
||||
Moon as PixelMoon,
|
||||
MoreVertical as PixelMoreVertical,
|
||||
NoteDelete as PixelNoteDelete,
|
||||
Plus as PixelPlus,
|
||||
Server as PixelServer,
|
||||
Sun as PixelSun,
|
||||
Trash as PixelTrash,
|
||||
Undo as PixelUndo,
|
||||
User as PixelUser,
|
||||
ViewportWide as PixelViewportWide,
|
||||
Alert as PixelAlert,
|
||||
Check as PixelCheck,
|
||||
ChevronDown as PixelChevronDown,
|
||||
ChevronLeft as PixelChevronLeft,
|
||||
ChevronRight as PixelChevronRight,
|
||||
ChevronUp as PixelChevronUp,
|
||||
Circle as PixelCircle,
|
||||
Clock as PixelClock,
|
||||
Close as PixelClose,
|
||||
Edit as PixelEdit,
|
||||
Home as PixelHome,
|
||||
InfoBox as PixelInfo,
|
||||
Link as PixelLink,
|
||||
Loader as PixelLoader,
|
||||
Logout as PixelLogout,
|
||||
Moon as PixelMoon,
|
||||
MoreVertical as PixelMoreVertical,
|
||||
NoteDelete as PixelNoteDelete,
|
||||
Plus as PixelPlus,
|
||||
Server as PixelServer,
|
||||
Sun as PixelSun,
|
||||
Trash as PixelTrash,
|
||||
Undo as PixelUndo,
|
||||
User as PixelUser,
|
||||
ViewportWide as PixelViewportWide,
|
||||
} from "@nsmr/pixelart-react";
|
||||
import {
|
||||
CheckIcon as PhosphorCheck,
|
||||
CheckCircleIcon as PhosphorCheckCircle,
|
||||
CaretDownIcon as PhosphorChevronDown,
|
||||
CaretLeftIcon as PhosphorChevronLeft,
|
||||
CaretRightIcon as PhosphorChevronRight,
|
||||
CaretUpIcon as PhosphorChevronUp,
|
||||
CircleIcon as PhosphorCircle,
|
||||
ClockIcon as PhosphorClock,
|
||||
DotsSixVerticalIcon as PhosphorDotsSixVertical,
|
||||
DotsThreeVerticalIcon as PhosphorDotsThreeVertical,
|
||||
PencilSimpleIcon as PhosphorEdit,
|
||||
HashIcon as PhosphorHash,
|
||||
HashStraightIcon as PhosphorHashStraight,
|
||||
HouseIcon as PhosphorHome,
|
||||
InfoIcon as PhosphorInfo,
|
||||
LinkIcon as PhosphorLink,
|
||||
SpinnerGapIcon as PhosphorLoader,
|
||||
SignOutIcon as PhosphorLogOut,
|
||||
MoonIcon as PhosphorMoon,
|
||||
OctagonIcon as PhosphorOctagon,
|
||||
PlusIcon as PhosphorPlus,
|
||||
QuestionIcon as PhosphorQuestion,
|
||||
HardDrivesIcon as PhosphorServer,
|
||||
SunIcon as PhosphorSun,
|
||||
TrashIcon as PhosphorTrash,
|
||||
ArrowCounterClockwiseIcon as PhosphorUndo,
|
||||
UserIcon as PhosphorUser,
|
||||
WarningIcon as PhosphorWarning,
|
||||
XIcon as PhosphorX,
|
||||
CheckIcon as PhosphorCheck,
|
||||
CheckCircleIcon as PhosphorCheckCircle,
|
||||
CaretDownIcon as PhosphorChevronDown,
|
||||
CaretLeftIcon as PhosphorChevronLeft,
|
||||
CaretRightIcon as PhosphorChevronRight,
|
||||
CaretUpIcon as PhosphorChevronUp,
|
||||
CircleIcon as PhosphorCircle,
|
||||
ClockIcon as PhosphorClock,
|
||||
DotsSixVerticalIcon as PhosphorDotsSixVertical,
|
||||
DotsThreeVerticalIcon as PhosphorDotsThreeVertical,
|
||||
PencilSimpleIcon as PhosphorEdit,
|
||||
HashIcon as PhosphorHash,
|
||||
HashStraightIcon as PhosphorHashStraight,
|
||||
HouseIcon as PhosphorHome,
|
||||
InfoIcon as PhosphorInfo,
|
||||
LinkIcon as PhosphorLink,
|
||||
SpinnerGapIcon as PhosphorLoader,
|
||||
SignOutIcon as PhosphorLogOut,
|
||||
MoonIcon as PhosphorMoon,
|
||||
OctagonIcon as PhosphorOctagon,
|
||||
PlusIcon as PhosphorPlus,
|
||||
QuestionIcon as PhosphorQuestion,
|
||||
HardDrivesIcon as PhosphorServer,
|
||||
SunIcon as PhosphorSun,
|
||||
TrashIcon as PhosphorTrash,
|
||||
ArrowCounterClockwiseIcon as PhosphorUndo,
|
||||
UserIcon as PhosphorUser,
|
||||
WarningIcon as PhosphorWarning,
|
||||
XIcon as PhosphorX,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { IconStyle } from "@sprint/shared";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUp,
|
||||
ChevronUpIcon,
|
||||
CircleCheckIcon,
|
||||
CircleIcon,
|
||||
CircleQuestionMark,
|
||||
Edit,
|
||||
EllipsisVertical,
|
||||
GripVerticalIcon,
|
||||
Hash,
|
||||
InfoIcon,
|
||||
Link,
|
||||
Loader,
|
||||
Loader2Icon,
|
||||
LogOut,
|
||||
Home as LucideHome,
|
||||
Moon,
|
||||
OctagonXIcon,
|
||||
Plus,
|
||||
ServerIcon,
|
||||
Sun,
|
||||
Timer,
|
||||
Trash,
|
||||
TriangleAlertIcon,
|
||||
Undo,
|
||||
Undo2,
|
||||
UserRound,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUp,
|
||||
ChevronUpIcon,
|
||||
CircleCheckIcon,
|
||||
CircleIcon,
|
||||
CircleQuestionMark,
|
||||
Edit,
|
||||
EllipsisVertical,
|
||||
GripVerticalIcon,
|
||||
Hash,
|
||||
InfoIcon,
|
||||
Link,
|
||||
Loader,
|
||||
Loader2Icon,
|
||||
LogOut,
|
||||
Home as LucideHome,
|
||||
Moon,
|
||||
OctagonXIcon,
|
||||
Plus,
|
||||
ServerIcon,
|
||||
Sun,
|
||||
Timer,
|
||||
Trash,
|
||||
TriangleAlertIcon,
|
||||
Undo,
|
||||
Undo2,
|
||||
UserRound,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useSessionSafe } from "@/components/session-provider";
|
||||
|
||||
const icons = {
|
||||
alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning },
|
||||
check: { lucide: Check, pixel: PixelCheck, phosphor: PhosphorCheck },
|
||||
checkIcon: { lucide: CheckIcon, pixel: PixelCheck, phosphor: PhosphorCheck },
|
||||
chevronDown: { lucide: ChevronDown, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
|
||||
chevronDownIcon: { lucide: ChevronDownIcon, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
|
||||
chevronLeftIcon: { lucide: ChevronLeftIcon, pixel: PixelChevronLeft, phosphor: PhosphorChevronLeft },
|
||||
chevronRightIcon: { lucide: ChevronRightIcon, pixel: PixelChevronRight, phosphor: PhosphorChevronRight },
|
||||
chevronUp: { lucide: ChevronUp, pixel: PixelChevronUp, phosphor: PhosphorChevronUp },
|
||||
chevronUpIcon: { lucide: ChevronUpIcon, pixel: PixelChevronUp, phosphor: PhosphorChevronUp },
|
||||
circleCheckIcon: { lucide: CircleCheckIcon, pixel: PixelCheck, phosphor: PhosphorCheckCircle },
|
||||
circleIcon: { lucide: CircleIcon, pixel: PixelCircle, phosphor: PhosphorCircle },
|
||||
circleQuestionMark: { lucide: CircleQuestionMark, pixel: PixelNoteDelete, phosphor: PhosphorQuestion },
|
||||
edit: { lucide: Edit, pixel: PixelEdit, phosphor: PhosphorEdit },
|
||||
ellipsisVertical: {
|
||||
lucide: EllipsisVertical,
|
||||
pixel: PixelMoreVertical,
|
||||
phosphor: PhosphorDotsThreeVertical,
|
||||
},
|
||||
gripVerticalIcon: {
|
||||
lucide: GripVerticalIcon,
|
||||
pixel: PixelViewportWide,
|
||||
phosphor: PhosphorDotsSixVertical,
|
||||
},
|
||||
hash: { lucide: Hash, pixel: PhosphorHashStraight, phosphor: PhosphorHash },
|
||||
home: { lucide: LucideHome, pixel: PixelHome, phosphor: PhosphorHome },
|
||||
infoIcon: { lucide: InfoIcon, pixel: PixelInfo, phosphor: PhosphorInfo },
|
||||
link: { lucide: Link, pixel: PixelLink, phosphor: PhosphorLink },
|
||||
loader: { lucide: Loader, pixel: PixelLoader, phosphor: PhosphorLoader },
|
||||
loader2Icon: { lucide: Loader2Icon, pixel: PixelLoader, phosphor: PhosphorLoader },
|
||||
logOut: { lucide: LogOut, pixel: PixelLogout, phosphor: PhosphorLogOut },
|
||||
moon: { lucide: Moon, pixel: PixelMoon, phosphor: PhosphorMoon },
|
||||
octagonXIcon: { lucide: OctagonXIcon, pixel: PixelClose, phosphor: PhosphorOctagon },
|
||||
plus: { lucide: Plus, pixel: PixelPlus, phosphor: PhosphorPlus },
|
||||
serverIcon: { lucide: ServerIcon, pixel: PixelServer, phosphor: PhosphorServer },
|
||||
sun: { lucide: Sun, pixel: PixelSun, phosphor: PhosphorSun },
|
||||
timer: { lucide: Timer, pixel: PixelClock, phosphor: PhosphorClock },
|
||||
trash: { lucide: Trash, pixel: PixelTrash, phosphor: PhosphorTrash },
|
||||
triangleAlertIcon: { lucide: TriangleAlertIcon, pixel: PixelAlert, phosphor: PhosphorWarning },
|
||||
undo: { lucide: Undo, pixel: PixelUndo, phosphor: PhosphorUndo },
|
||||
undo2: { lucide: Undo2, pixel: PixelUndo, phosphor: PhosphorUndo },
|
||||
userRound: { lucide: UserRound, pixel: PixelUser, phosphor: PhosphorUser },
|
||||
x: { lucide: X, pixel: PixelClose, phosphor: PhosphorX },
|
||||
alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning },
|
||||
check: { lucide: Check, pixel: PixelCheck, phosphor: PhosphorCheck },
|
||||
checkIcon: { lucide: CheckIcon, pixel: PixelCheck, phosphor: PhosphorCheck },
|
||||
chevronDown: { lucide: ChevronDown, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
|
||||
chevronDownIcon: { lucide: ChevronDownIcon, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
|
||||
chevronLeftIcon: { lucide: ChevronLeftIcon, pixel: PixelChevronLeft, phosphor: PhosphorChevronLeft },
|
||||
chevronRightIcon: { lucide: ChevronRightIcon, pixel: PixelChevronRight, phosphor: PhosphorChevronRight },
|
||||
chevronUp: { lucide: ChevronUp, pixel: PixelChevronUp, phosphor: PhosphorChevronUp },
|
||||
chevronUpIcon: { lucide: ChevronUpIcon, pixel: PixelChevronUp, phosphor: PhosphorChevronUp },
|
||||
circleCheckIcon: { lucide: CircleCheckIcon, pixel: PixelCheck, phosphor: PhosphorCheckCircle },
|
||||
circleIcon: { lucide: CircleIcon, pixel: PixelCircle, phosphor: PhosphorCircle },
|
||||
circleQuestionMark: { lucide: CircleQuestionMark, pixel: PixelNoteDelete, phosphor: PhosphorQuestion },
|
||||
edit: { lucide: Edit, pixel: PixelEdit, phosphor: PhosphorEdit },
|
||||
ellipsisVertical: {
|
||||
lucide: EllipsisVertical,
|
||||
pixel: PixelMoreVertical,
|
||||
phosphor: PhosphorDotsThreeVertical,
|
||||
},
|
||||
gripVerticalIcon: {
|
||||
lucide: GripVerticalIcon,
|
||||
pixel: PixelViewportWide,
|
||||
phosphor: PhosphorDotsSixVertical,
|
||||
},
|
||||
hash: { lucide: Hash, pixel: PhosphorHashStraight, phosphor: PhosphorHash },
|
||||
home: { lucide: LucideHome, pixel: PixelHome, phosphor: PhosphorHome },
|
||||
infoIcon: { lucide: InfoIcon, pixel: PixelInfo, phosphor: PhosphorInfo },
|
||||
link: { lucide: Link, pixel: PixelLink, phosphor: PhosphorLink },
|
||||
loader: { lucide: Loader, pixel: PixelLoader, phosphor: PhosphorLoader },
|
||||
loader2Icon: { lucide: Loader2Icon, pixel: PixelLoader, phosphor: PhosphorLoader },
|
||||
logOut: { lucide: LogOut, pixel: PixelLogout, phosphor: PhosphorLogOut },
|
||||
moon: { lucide: Moon, pixel: PixelMoon, phosphor: PhosphorMoon },
|
||||
octagonXIcon: { lucide: OctagonXIcon, pixel: PixelClose, phosphor: PhosphorOctagon },
|
||||
plus: { lucide: Plus, pixel: PixelPlus, phosphor: PhosphorPlus },
|
||||
serverIcon: { lucide: ServerIcon, pixel: PixelServer, phosphor: PhosphorServer },
|
||||
sun: { lucide: Sun, pixel: PixelSun, phosphor: PhosphorSun },
|
||||
timer: { lucide: Timer, pixel: PixelClock, phosphor: PhosphorClock },
|
||||
trash: { lucide: Trash, pixel: PixelTrash, phosphor: PhosphorTrash },
|
||||
triangleAlertIcon: { lucide: TriangleAlertIcon, pixel: PixelAlert, phosphor: PhosphorWarning },
|
||||
undo: { lucide: Undo, pixel: PixelUndo, phosphor: PhosphorUndo },
|
||||
undo2: { lucide: Undo2, pixel: PixelUndo, phosphor: PhosphorUndo },
|
||||
userRound: { lucide: UserRound, pixel: PixelUser, phosphor: PhosphorUser },
|
||||
x: { lucide: X, pixel: PixelClose, phosphor: PhosphorX },
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
@@ -146,41 +146,41 @@ export const iconStyles = ["lucide", "pixel", "phosphor"] as const;
|
||||
export type { IconStyle };
|
||||
|
||||
export default function Icon({
|
||||
icon,
|
||||
iconStyle,
|
||||
size = 24,
|
||||
...props
|
||||
icon,
|
||||
iconStyle,
|
||||
size = 24,
|
||||
...props
|
||||
}: {
|
||||
icon: IconName;
|
||||
iconStyle?: IconStyle;
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
icon: IconName;
|
||||
iconStyle?: IconStyle;
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
} & React.ComponentProps<"svg">) {
|
||||
const session = useSessionSafe();
|
||||
const resolvedStyle = (iconStyle ??
|
||||
session?.user?.iconPreference ??
|
||||
localStorage.getItem("iconPreference") ??
|
||||
"lucide") as IconStyle;
|
||||
const IconComponent = icons[icon]?.[resolvedStyle];
|
||||
const session = useSessionSafe();
|
||||
const resolvedStyle = (iconStyle ??
|
||||
session?.user?.iconPreference ??
|
||||
localStorage.getItem("iconPreference") ??
|
||||
"lucide") as IconStyle;
|
||||
const IconComponent = icons[icon]?.[resolvedStyle];
|
||||
|
||||
if (localStorage.getItem("iconPreference") !== resolvedStyle)
|
||||
localStorage.setItem("iconPreference", resolvedStyle);
|
||||
if (localStorage.getItem("iconPreference") !== resolvedStyle)
|
||||
localStorage.setItem("iconPreference", resolvedStyle);
|
||||
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
}
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
size={size}
|
||||
fill={
|
||||
(resolvedStyle === "pixel" && icon === "moon") ||
|
||||
(resolvedStyle === "pixel" && icon === "hash") ||
|
||||
resolvedStyle === "phosphor"
|
||||
? "var(--foreground)"
|
||||
: "transparent"
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<IconComponent
|
||||
size={size}
|
||||
fill={
|
||||
(resolvedStyle === "pixel" && icon === "moon") ||
|
||||
(resolvedStyle === "pixel" && icon === "hash") ||
|
||||
resolvedStyle === "phosphor"
|
||||
? "var(--foreground)"
|
||||
: "transparent"
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,65 +3,65 @@ import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
showCounter = true,
|
||||
showHashPrefix = false,
|
||||
inputClassName,
|
||||
...props
|
||||
className,
|
||||
type,
|
||||
showCounter = true,
|
||||
showHashPrefix = false,
|
||||
inputClassName,
|
||||
...props
|
||||
}: React.ComponentProps<"input"> & {
|
||||
showCounter?: boolean;
|
||||
showHashPrefix?: boolean;
|
||||
inputClassName?: string;
|
||||
showCounter?: boolean;
|
||||
showHashPrefix?: boolean;
|
||||
inputClassName?: string;
|
||||
}) {
|
||||
const maxLength = typeof props.maxLength === "number" ? props.maxLength : undefined;
|
||||
const currentLength = typeof props.value === "string" ? props.value.length : undefined;
|
||||
const maxLength = typeof props.maxLength === "number" ? props.maxLength : undefined;
|
||||
const currentLength = typeof props.value === "string" ? props.value.length : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 flex h-9 w-full min-w-0 items-center border bg-transparent",
|
||||
"transition-[color,box-shadow]",
|
||||
"has-[:focus-visible]:border-ring",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 flex h-9 w-full min-w-0 items-center border bg-transparent",
|
||||
"transition-[color,box-shadow]",
|
||||
"has-[:focus-visible]:border-ring",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showHashPrefix && (
|
||||
<span className="border-r px-1 py-1 text-muted-foreground">
|
||||
<Icon icon="hash" className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
||||
"h-full flex-1 min-w-0 bg-transparent px-3 py-1 pr-1 text-base outline-none",
|
||||
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
showHashPrefix ? "pl-2 py-0" : "pl-3",
|
||||
inputClassName,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{showCounter && currentLength !== undefined && maxLength !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"border-l px-2 h-full flex w-fit items-center justify-center text-[11px] tabular-nums",
|
||||
currentLength / maxLength >= 1
|
||||
? "text-destructive"
|
||||
: currentLength / maxLength >= 0.75
|
||||
? "text-amber-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{showHashPrefix && (
|
||||
<span className="border-r px-1 py-1 text-muted-foreground">
|
||||
<Icon icon="hash" className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
||||
"h-full flex-1 min-w-0 bg-transparent px-3 py-1 pr-1 text-base outline-none",
|
||||
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
showHashPrefix ? "pl-2 py-0" : "pl-3",
|
||||
inputClassName,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{showCounter && currentLength !== undefined && maxLength !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"border-l px-2 h-full flex w-fit items-center justify-center text-[11px] tabular-nums",
|
||||
currentLength / maxLength >= 1
|
||||
? "text-destructive"
|
||||
: currentLength / maxLength >= 0.75
|
||||
? "text-amber-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{String(currentLength).padStart(String(maxLength).length, "0")}/{maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{String(currentLength).padStart(String(maxLength).length, "0")}/{maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
|
||||
@@ -4,16 +4,16 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
|
||||
@@ -4,39 +4,39 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 w-72",
|
||||
"origin-(--radix-popover-content-transform-origin) border p-4",
|
||||
"shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 w-72",
|
||||
"origin-(--radix-popover-content-transform-origin) border p-4",
|
||||
"shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
||||
@@ -7,53 +7,53 @@ import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({ className, ...props }: React.ComponentProps<typeof Group>) {
|
||||
return (
|
||||
<Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
|
||||
return <Panel data-slot="resizable-panel" {...props} />;
|
||||
return <Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableSeparator({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator> & {
|
||||
withHandle?: boolean;
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-1 items-center justify-center",
|
||||
"after:absolute after:inset-y-0 after:left-1/2",
|
||||
"after:w-1 after:-translate-x-1/2 focus-visible:ring-0",
|
||||
"focus-visible:ring-offset-0 focus-visible:outline-hidden",
|
||||
"data-[panel-group-direction=vertical]:h-px",
|
||||
"data-[panel-group-direction=vertical]:w-full",
|
||||
"data-[panel-group-direction=vertical]:after:left-0",
|
||||
"data-[panel-group-direction=vertical]:after:h-1",
|
||||
"data-[panel-group-direction=vertical]:after:w-full",
|
||||
"data-[panel-group-direction=vertical]:after:translate-x-0",
|
||||
"data-[panel-group-direction=vertical]:after:-translate-y-1/2",
|
||||
"[&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<Icon icon="gripVerticalIcon" className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</Separator>
|
||||
);
|
||||
return (
|
||||
<Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-1 items-center justify-center",
|
||||
"after:absolute after:inset-y-0 after:left-1/2",
|
||||
"after:w-1 after:-translate-x-1/2 focus-visible:ring-0",
|
||||
"focus-visible:ring-offset-0 focus-visible:outline-hidden",
|
||||
"data-[panel-group-direction=vertical]:h-px",
|
||||
"data-[panel-group-direction=vertical]:w-full",
|
||||
"data-[panel-group-direction=vertical]:after:left-0",
|
||||
"data-[panel-group-direction=vertical]:after:h-1",
|
||||
"data-[panel-group-direction=vertical]:after:w-full",
|
||||
"data-[panel-group-direction=vertical]:after:translate-x-0",
|
||||
"data-[panel-group-direction=vertical]:after:-translate-y-1/2",
|
||||
"[&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<Icon icon="gripVerticalIcon" className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableSeparator };
|
||||
|
||||
@@ -5,228 +5,228 @@ import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
variant = "default",
|
||||
children,
|
||||
isOpen,
|
||||
label,
|
||||
hasValue,
|
||||
labelPosition = "top",
|
||||
chevronClassName,
|
||||
...props
|
||||
className,
|
||||
size = "default",
|
||||
variant = "default",
|
||||
children,
|
||||
isOpen,
|
||||
label,
|
||||
hasValue,
|
||||
labelPosition = "top",
|
||||
chevronClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
isOpen?: boolean;
|
||||
size?: "sm" | "default";
|
||||
variant?: "default" | "unstyled";
|
||||
label?: string;
|
||||
hasValue?: boolean;
|
||||
labelPosition?: "top" | "bottom";
|
||||
chevronClassName?: string;
|
||||
isOpen?: boolean;
|
||||
size?: "sm" | "default";
|
||||
variant?: "default" | "unstyled";
|
||||
label?: string;
|
||||
hasValue?: boolean;
|
||||
labelPosition?: "top" | "bottom";
|
||||
chevronClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
variant === "unstyled"
|
||||
? "cursor-pointer bg-transparent shadow-none outline-none"
|
||||
: [
|
||||
"cursor-pointer border data-[placeholder]:text-muted-foreground",
|
||||
"[&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
"aria-invalid:border-destructive dark:hover:bg-muted/40",
|
||||
"relative flex w-fit items-center justify-between gap-2 border",
|
||||
"bg-transparent px-3 py-2 text-sm whitespace-nowrap",
|
||||
"shadow-xs outline-none disabled:cursor-not-allowed",
|
||||
"disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex",
|
||||
"*:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
variant === "unstyled"
|
||||
? "cursor-pointer bg-transparent shadow-none outline-none"
|
||||
: [
|
||||
"cursor-pointer border data-[placeholder]:text-muted-foreground",
|
||||
"[&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
"aria-invalid:border-destructive dark:hover:bg-muted/40",
|
||||
"relative flex w-fit items-center justify-between gap-2 border",
|
||||
"bg-transparent px-3 py-2 text-sm whitespace-nowrap",
|
||||
"shadow-xs outline-none disabled:cursor-not-allowed",
|
||||
"disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex",
|
||||
"*:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{label && hasValue && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground bg-background absolute left-1 text-[12px] leading-none font-700",
|
||||
labelPosition === "top" ? "-top-1" : "-bottom-1",
|
||||
)}
|
||||
>
|
||||
{label && hasValue && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground bg-background absolute left-1 text-[12px] leading-none font-700",
|
||||
labelPosition === "top" ? "-top-1" : "-bottom-1",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<Icon
|
||||
icon="chevronDownIcon"
|
||||
className={cn("size-4.5 opacity-50", chevronClassName)}
|
||||
style={{ rotate: isOpen ? "180deg" : "0deg" }}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<Icon
|
||||
icon="chevronDownIcon"
|
||||
className={cn("size-4.5 opacity-50", chevronClassName)}
|
||||
style={{ rotate: isOpen ? "180deg" : "0deg" }}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align={align}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2",
|
||||
"data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
|
||||
"data-[side=top]:slide-in-from-bottom-2 relative z-50",
|
||||
"max-h-(--radix-select-content-available-height) min-w-[8rem]",
|
||||
"origin-(--radix-select-content-transform-origin) overflow-x-hidden",
|
||||
"overflow-y-auto border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=bottom]:-translate-x-1.5 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1 data-[side=top]:-translate-x-0.5",
|
||||
position === "popper" && align === "start" && "data-[side=bottom]:-translate-x-0",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align={align}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2",
|
||||
"data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
|
||||
"data-[side=top]:slide-in-from-bottom-2 relative z-50",
|
||||
"max-h-(--radix-select-content-available-height) min-w-[8rem]",
|
||||
"origin-(--radix-select-content-transform-origin) overflow-x-hidden",
|
||||
"overflow-y-auto border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=bottom]:-translate-x-1.5 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1 data-[side=top]:-translate-x-0.5",
|
||||
position === "popper" && align === "start" && "data-[side=bottom]:-translate-x-0",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs font-700", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs font-700", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
textClassName,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
textClassName,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
textClassName?: string;
|
||||
textClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent/40 focus:text-accent-foreground",
|
||||
"[&_svg:not([class*='text-'])]:text-muted-foreground relative",
|
||||
"flex w-full cursor-pointer items-center gap-2 py-1.5 pr-8 pl-2",
|
||||
"text-sm outline-hidden select-none data-[disabled]:pointer-events-none",
|
||||
"data-[disabled]:opacity-50 [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Icon icon="checkIcon" className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{textClassName ? (
|
||||
<span className={cn(textClassName)}>{children}</span>
|
||||
) : (
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent/40 focus:text-accent-foreground",
|
||||
"[&_svg:not([class*='text-'])]:text-muted-foreground relative",
|
||||
"flex w-full cursor-pointer items-center gap-2 py-1.5 pr-8 pl-2",
|
||||
"text-sm outline-hidden select-none data-[disabled]:pointer-events-none",
|
||||
"data-[disabled]:opacity-50 [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Icon icon="checkIcon" className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{textClassName ? (
|
||||
<span className={cn(textClassName)}>{children}</span>
|
||||
) : (
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon="chevronUpIcon" className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon="chevronUpIcon" className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon="chevronDownIcon" className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon="chevronDownIcon" className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
@@ -3,30 +3,30 @@ import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
import Icon from "@/components/ui/icon";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <Icon icon="circleCheckIcon" className="size-4" />,
|
||||
info: <Icon icon="infoIcon" className="size-4" />,
|
||||
warning: <Icon icon="triangleAlertIcon" className="size-4" />,
|
||||
error: <Icon icon="octagonXIcon" className="size-4" />,
|
||||
loading: <Icon icon="loader2Icon" className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "0",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <Icon icon="circleCheckIcon" className="size-4" />,
|
||||
info: <Icon icon="infoIcon" className="size-4" />,
|
||||
warning: <Icon icon="triangleAlertIcon" className="size-4" />,
|
||||
error: <Icon icon="octagonXIcon" className="size-4" />,
|
||||
loading: <Icon icon="loader2Icon" className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "0",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
|
||||
@@ -2,15 +2,15 @@ import Icon from "@/components/ui/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Icon
|
||||
icon="loader"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Icon
|
||||
icon="loader"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
|
||||
@@ -3,86 +3,84 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-hidden">
|
||||
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-hidden">
|
||||
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
);
|
||||
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({
|
||||
className,
|
||||
hoverEffect = true,
|
||||
...props
|
||||
className,
|
||||
hoverEffect = true,
|
||||
...props
|
||||
}: React.ComponentProps<"tr"> & { hoverEffect?: boolean }) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"data-[state=selected]:bg-muted h-[25px] border-b",
|
||||
hoverEffect && "hover:bg-muted/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"data-[state=selected]:bg-muted h-[25px] border-b",
|
||||
hoverEffect && "hover:bg-muted/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground px-2 h-[25px] text-left text-sm align-middle font-medium",
|
||||
"whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground px-2 h-[25px] text-left text-sm align-middle font-medium",
|
||||
"whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"px-2 py-1 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"px-2 py-1 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
|
||||
@@ -4,53 +4,51 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
);
|
||||
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"border text-muted-foreground inline-flex h-9 w-fit items-center justify-center p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"border text-muted-foreground inline-flex h-9 w-fit items-center justify-center p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring",
|
||||
"dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30",
|
||||
"text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)]",
|
||||
"flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1",
|
||||
"text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
||||
"focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none",
|
||||
"disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring",
|
||||
"dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30",
|
||||
"text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)]",
|
||||
"flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1",
|
||||
"text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
|
||||
"focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none",
|
||||
"disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none",
|
||||
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -3,23 +3,23 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 w-full min-w-0 border bg-transparent",
|
||||
"transition-[color,box-shadow]",
|
||||
"focus-visible:border-ring",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"field-sizing-content min-h-2 px-3 py-2 text-base md:text-sm resize-none",
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 w-full min-w-0 border bg-transparent",
|
||||
"transition-[color,box-shadow]",
|
||||
"focus-visible:border-ring",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"field-sizing-content min-h-2 px-3 py-2 text-base md:text-sm resize-none",
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
|
||||
@@ -9,89 +9,84 @@ import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function UploadAvatar({
|
||||
name,
|
||||
username,
|
||||
avatarURL,
|
||||
onAvatarUploaded,
|
||||
className,
|
||||
name,
|
||||
username,
|
||||
avatarURL,
|
||||
onAvatarUploaded,
|
||||
className,
|
||||
}: {
|
||||
name?: string;
|
||||
username?: string;
|
||||
avatarURL?: string | null;
|
||||
onAvatarUploaded: (avatarURL: string) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
avatarURL?: string | null;
|
||||
onAvatarUploaded: (avatarURL: string) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = await uploadAvatar.mutateAsync(file);
|
||||
onAvatarUploaded(url);
|
||||
setUploading(false);
|
||||
try {
|
||||
const url = await uploadAvatar.mutateAsync(file);
|
||||
onAvatarUploaded(url);
|
||||
setUploading(false);
|
||||
|
||||
toast.success(
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
Avatar uploaded successfully
|
||||
</div>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
setError(message);
|
||||
setUploading(false);
|
||||
toast.success(<div className="flex flex-col items-center gap-4">Avatar uploaded successfully</div>, {
|
||||
dismissible: false,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
setError(message);
|
||||
setUploading(false);
|
||||
|
||||
toast.error(`Error uploading avatar: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
toast.error(`Error uploading avatar: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center gap-4", className)}>
|
||||
<Button
|
||||
variant="dummy"
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onMouseOver={() => setShowEdit(true)}
|
||||
onMouseOut={() => setShowEdit(false)}
|
||||
className="w-24 h-24 rounded-full border-1 p-0 relative overflow-hidden"
|
||||
>
|
||||
<Avatar
|
||||
name={name}
|
||||
username={username}
|
||||
avatarURL={avatarURL}
|
||||
size={24}
|
||||
textClass={"text-4xl"}
|
||||
strong
|
||||
/>
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center gap-4", className)}>
|
||||
<Button
|
||||
variant="dummy"
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onMouseOver={() => setShowEdit(true)}
|
||||
onMouseOut={() => setShowEdit(false)}
|
||||
className="w-24 h-24 rounded-full border-1 p-0 relative overflow-hidden"
|
||||
>
|
||||
<Avatar
|
||||
name={name}
|
||||
username={username}
|
||||
avatarURL={avatarURL}
|
||||
size={24}
|
||||
textClass={"text-4xl"}
|
||||
strong
|
||||
/>
|
||||
|
||||
{!uploading && showEdit && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<Icon icon="edit" className="size-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
/>
|
||||
{error && <Label className="text-destructive text-sm">{error}</Label>}
|
||||
</div>
|
||||
);
|
||||
{!uploading && showEdit && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<Icon icon="edit" className="size-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
/>
|
||||
{error && <Label className="text-destructive text-sm">{error}</Label>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,83 +9,81 @@ import { parseError } from "@/lib/server";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function UploadOrgIcon({
|
||||
name,
|
||||
slug,
|
||||
iconURL,
|
||||
organisationId,
|
||||
onIconUploaded,
|
||||
className,
|
||||
name,
|
||||
slug,
|
||||
iconURL,
|
||||
organisationId,
|
||||
onIconUploaded,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
slug: string;
|
||||
iconURL?: string | null;
|
||||
organisationId: number;
|
||||
onIconUploaded: (iconURL: string) => void;
|
||||
className?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
iconURL?: string | null;
|
||||
organisationId: number;
|
||||
onIconUploaded: (iconURL: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const uploadIcon = useUploadOrganisationIcon();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const uploadIcon = useUploadOrganisationIcon();
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = await uploadIcon.mutateAsync({ file, organisationId });
|
||||
onIconUploaded(url);
|
||||
setUploading(false);
|
||||
try {
|
||||
const url = await uploadIcon.mutateAsync({ file, organisationId });
|
||||
onIconUploaded(url);
|
||||
setUploading(false);
|
||||
|
||||
toast.success(
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
Organisation icon uploaded successfully
|
||||
</div>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
setError(message);
|
||||
setUploading(false);
|
||||
toast.success(
|
||||
<div className="flex flex-col items-center gap-4">Organisation icon uploaded successfully</div>,
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const message = parseError(err as Error);
|
||||
setError(message);
|
||||
setUploading(false);
|
||||
|
||||
toast.error(`Error uploading icon: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
toast.error(`Error uploading icon: ${message}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center gap-4", className)}>
|
||||
<Button
|
||||
variant="dummy"
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onMouseOver={() => setShowEdit(true)}
|
||||
onMouseOut={() => setShowEdit(false)}
|
||||
className="size-24 rounded-lg border-1 p-0 relative overflow-hidden"
|
||||
>
|
||||
<OrgIcon name={name} slug={slug} iconURL={iconURL} size={24} textClass="text-4xl" />
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center gap-4", className)}>
|
||||
<Button
|
||||
variant="dummy"
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onMouseOver={() => setShowEdit(true)}
|
||||
onMouseOut={() => setShowEdit(false)}
|
||||
className="size-24 rounded-lg border-1 p-0 relative overflow-hidden"
|
||||
>
|
||||
<OrgIcon name={name} slug={slug} iconURL={iconURL} size={24} textClass="text-4xl" />
|
||||
|
||||
{!uploading && showEdit && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<Icon icon="edit" className="size-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
/>
|
||||
{error && <Label className="text-destructive text-sm">{error}</Label>}
|
||||
</div>
|
||||
);
|
||||
{!uploading && showEdit && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<Icon icon="edit" className="size-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
/>
|
||||
{error && <Label className="text-destructive text-sm">{error}</Label>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,56 +4,56 @@ import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export function UserSelect({
|
||||
users,
|
||||
value,
|
||||
onChange,
|
||||
fallbackUser,
|
||||
placeholder = "Select user",
|
||||
users,
|
||||
value,
|
||||
onChange,
|
||||
fallbackUser,
|
||||
placeholder = "Select user",
|
||||
}: {
|
||||
users: UserRecord[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
fallbackUser?: UserRecord | null;
|
||||
placeholder?: string;
|
||||
users: UserRecord[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
fallbackUser?: UserRecord | null;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const renderSelectedValue = () => {
|
||||
if (value === "unassigned") {
|
||||
return "Unassigned";
|
||||
}
|
||||
const renderSelectedValue = () => {
|
||||
if (value === "unassigned") {
|
||||
return "Unassigned";
|
||||
}
|
||||
|
||||
const user = users.find((u) => u.id.toString() === value);
|
||||
const className = "p-0 py-2 text-sm";
|
||||
const user = users.find((u) => u.id.toString() === value);
|
||||
const className = "p-0 py-2 text-sm";
|
||||
|
||||
if (user) {
|
||||
return <SmallUserDisplay user={user} className={className} />;
|
||||
}
|
||||
if (user) {
|
||||
return <SmallUserDisplay user={user} className={className} />;
|
||||
}
|
||||
|
||||
if (fallbackUser) {
|
||||
return <SmallUserDisplay user={fallbackUser} className={className} />;
|
||||
}
|
||||
if (fallbackUser) {
|
||||
return <SmallUserDisplay user={fallbackUser} className={className} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
|
||||
<SelectTrigger className="w-fit p-0 px-2 py-2" isOpen={isOpen}>
|
||||
<SelectValue placeholder={placeholder}>{renderSelectedValue()}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
side="bottom"
|
||||
position="popper"
|
||||
className="data-[side=bottom]:translate-y-1 data-[side=bottom]:translate-x-1"
|
||||
>
|
||||
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id.toString()}>
|
||||
<SmallUserDisplay user={user} className="p-0" />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
|
||||
<SelectTrigger className="w-fit p-0 px-2 py-2" isOpen={isOpen}>
|
||||
<SelectValue placeholder={placeholder}>{renderSelectedValue()}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
side="bottom"
|
||||
position="popper"
|
||||
className="data-[side=bottom]:translate-y-1 data-[side=bottom]:translate-x-1"
|
||||
>
|
||||
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id.toString()}>
|
||||
<SmallUserDisplay user={user} className="p-0" />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
@font-face {
|
||||
font-family: "Commit Mono";
|
||||
src: url("/fonts/CommitMono-Variable.woff2") format("woff2");
|
||||
font-weight: 200 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-family: "Commit Mono";
|
||||
src: url("/fonts/CommitMono-Variable.woff2") format("woff2");
|
||||
font-weight: 200 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Commit Mono";
|
||||
src: url("/fonts/CommitMono-Variable.woff2") format("woff2");
|
||||
font-weight: 200 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-family: "Commit Mono";
|
||||
src: url("/fonts/CommitMono-Variable.woff2") format("woff2");
|
||||
font-weight: 200 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Basteleur";
|
||||
src:
|
||||
url("/fonts/Basteleur-Bold.woff2") format("woff2"),
|
||||
url("/fonts/Basteleur-Bold.woff") format("woff");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-family: "Basteleur";
|
||||
src:
|
||||
url("/fonts/Basteleur-Bold.woff2") format("woff2"),
|
||||
url("/fonts/Basteleur-Bold.woff") format("woff");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Basteleur";
|
||||
src:
|
||||
url("/fonts/Basteleur-Moonlight.woff2") format("woff2"),
|
||||
url("/fonts/Basteleur-Moonlight.woff") format("woff");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-family: "Basteleur";
|
||||
src:
|
||||
url("/fonts/Basteleur-Moonlight.woff2") format("woff2"),
|
||||
url("/fonts/Basteleur-Moonlight.woff") format("woff");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Sorts Mill Goudy";
|
||||
src: url("/fonts/SortsMillGoudy-Regular.woff") format("woff");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-family: "Sorts Mill Goudy";
|
||||
src: url("/fonts/SortsMillGoudy-Regular.woff") format("woff");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Sorts Mill Goudy";
|
||||
src: url("/fonts/SortsMillGoudy-Italic.woff") format("woff");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-family: "Sorts Mill Goudy";
|
||||
src: url("/fonts/SortsMillGoudy-Italic.woff") format("woff");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
}
|
||||
|
||||
.font-basteleur {
|
||||
font-family: "Basteleur", serif;
|
||||
line-height: 1;
|
||||
transform: translateY(0.1em);
|
||||
font-family: "Basteleur", serif;
|
||||
line-height: 1;
|
||||
transform: translateY(0.1em);
|
||||
}
|
||||
|
||||
.font-goudy {
|
||||
font-family: "Sorts Mill Goudy", serif;
|
||||
font-family: "Sorts Mill Goudy", serif;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 mins
|
||||
gcTime: 10 * 60 * 1000, // 10 mins
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 mins
|
||||
gcTime: 10 * 60 * 1000, // 10 mins
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,31 +5,31 @@ import { useOrganisations } from "@/lib/query/hooks/organisations";
|
||||
import { useProjects } from "@/lib/query/hooks/projects";
|
||||
|
||||
export function useSelectedOrganisation() {
|
||||
const { selectedOrganisationId } = useSelection();
|
||||
const { data: organisations = [] } = useOrganisations();
|
||||
const { selectedOrganisationId } = useSelection();
|
||||
const { data: organisations = [] } = useOrganisations();
|
||||
|
||||
return useMemo(
|
||||
() => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
|
||||
[organisations, selectedOrganisationId],
|
||||
);
|
||||
return useMemo(
|
||||
() => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
|
||||
[organisations, selectedOrganisationId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSelectedProject() {
|
||||
const { selectedOrganisationId, selectedProjectId } = useSelection();
|
||||
const { data: projects = [] } = useProjects(selectedOrganisationId);
|
||||
const { selectedOrganisationId, selectedProjectId } = useSelection();
|
||||
const { data: projects = [] } = useProjects(selectedOrganisationId);
|
||||
|
||||
return useMemo(
|
||||
() => projects.find((project) => project.Project.id === selectedProjectId) ?? null,
|
||||
[projects, selectedProjectId],
|
||||
);
|
||||
return useMemo(
|
||||
() => projects.find((project) => project.Project.id === selectedProjectId) ?? null,
|
||||
[projects, selectedProjectId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSelectedIssue() {
|
||||
const { selectedProjectId, selectedIssueId } = useSelection();
|
||||
const { data: issues = [] } = useIssues(selectedProjectId);
|
||||
const { selectedProjectId, selectedIssueId } = useSelection();
|
||||
const { data: issues = [] } = useIssues(selectedProjectId);
|
||||
|
||||
return useMemo(
|
||||
() => issues.find((issue) => issue.Issue.id === selectedIssueId) ?? null,
|
||||
[issues, selectedIssueId],
|
||||
);
|
||||
return useMemo(
|
||||
() => issues.find((issue) => issue.Issue.id === selectedIssueId) ?? null,
|
||||
[issues, selectedIssueId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import type {
|
||||
IssueCreateRequest,
|
||||
IssueRecord,
|
||||
IssueResponse,
|
||||
IssuesReplaceStatusRequest,
|
||||
IssueUpdateRequest,
|
||||
StatusCountResponse,
|
||||
SuccessResponse,
|
||||
IssueCreateRequest,
|
||||
IssueRecord,
|
||||
IssueResponse,
|
||||
IssuesReplaceStatusRequest,
|
||||
IssueUpdateRequest,
|
||||
StatusCountResponse,
|
||||
SuccessResponse,
|
||||
} from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { issue } from "@/lib/server";
|
||||
|
||||
export function useIssues(projectId?: number | null) {
|
||||
return useQuery<IssueResponse[]>({
|
||||
queryKey: queryKeys.issues.byProject(projectId ?? 0),
|
||||
queryFn: () => issue.byProject(projectId ?? 0),
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
return useQuery<IssueResponse[]>({
|
||||
queryKey: queryKeys.issues.byProject(projectId ?? 0),
|
||||
queryFn: () => issue.byProject(projectId ?? 0),
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
|
||||
return useQuery<StatusCountResponse>({
|
||||
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""),
|
||||
queryFn: () => issue.statusCount(organisationId ?? 0, status ?? ""),
|
||||
enabled: Boolean(organisationId && status),
|
||||
});
|
||||
return useQuery<StatusCountResponse>({
|
||||
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""),
|
||||
queryFn: () => issue.statusCount(organisationId ?? 0, status ?? ""),
|
||||
enabled: Boolean(organisationId && status),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateIssue() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IssueRecord, Error, IssueCreateRequest>({
|
||||
mutationKey: ["issues", "create"],
|
||||
mutationFn: issue.create,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.byProject(variables.projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
return useMutation<IssueRecord, Error, IssueCreateRequest>({
|
||||
mutationKey: ["issues", "create"],
|
||||
mutationFn: issue.create,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.byProject(variables.projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateIssue() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IssueRecord, Error, IssueUpdateRequest>({
|
||||
mutationKey: ["issues", "update"],
|
||||
mutationFn: issue.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
});
|
||||
return useMutation<IssueRecord, Error, IssueUpdateRequest>({
|
||||
mutationKey: ["issues", "update"],
|
||||
mutationFn: issue.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteIssue() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<SuccessResponse, Error, number>({
|
||||
mutationKey: ["issues", "delete"],
|
||||
mutationFn: issue.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
});
|
||||
return useMutation<SuccessResponse, Error, number>({
|
||||
mutationKey: ["issues", "delete"],
|
||||
mutationFn: issue.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReplaceIssueStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<unknown, Error, IssuesReplaceStatusRequest>({
|
||||
mutationKey: ["issues", "replace-status"],
|
||||
mutationFn: issue.replaceStatus,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
});
|
||||
return useMutation<unknown, Error, IssuesReplaceStatusRequest>({
|
||||
mutationKey: ["issues", "replace-status"],
|
||||
mutationFn: issue.replaceStatus,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,113 +1,113 @@
|
||||
import type {
|
||||
OrgAddMemberRequest,
|
||||
OrganisationMemberRecord,
|
||||
OrganisationMemberResponse,
|
||||
OrgAddMemberRequest,
|
||||
OrganisationMemberRecord,
|
||||
OrganisationMemberResponse,
|
||||
} from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { organisation } from "@/lib/server";
|
||||
|
||||
export function useOrganisations() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.organisations.byUser(),
|
||||
queryFn: organisation.byUser,
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: queryKeys.organisations.byUser(),
|
||||
queryFn: organisation.byUser,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOrganisationMembers(organisationId?: number | null) {
|
||||
return useQuery<OrganisationMemberResponse[]>({
|
||||
queryKey: queryKeys.organisations.members(organisationId ?? 0),
|
||||
queryFn: () => organisation.members(organisationId ?? 0),
|
||||
enabled: Boolean(organisationId),
|
||||
});
|
||||
return useQuery<OrganisationMemberResponse[]>({
|
||||
queryKey: queryKeys.organisations.members(organisationId ?? 0),
|
||||
queryFn: () => organisation.members(organisationId ?? 0),
|
||||
enabled: Boolean(organisationId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateOrganisation() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "create"],
|
||||
mutationFn: organisation.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
});
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "create"],
|
||||
mutationFn: organisation.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOrganisation() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "update"],
|
||||
mutationFn: organisation.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
});
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "update"],
|
||||
mutationFn: organisation.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOrganisation() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "delete"],
|
||||
mutationFn: organisation.remove,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
});
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "delete"],
|
||||
mutationFn: organisation.remove,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddOrganisationMember() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<OrganisationMemberRecord, Error, OrgAddMemberRequest>({
|
||||
mutationKey: ["organisations", "members", "add"],
|
||||
mutationFn: organisation.addMember,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.organisations.members(variables.organisationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
return useMutation<OrganisationMemberRecord, Error, OrgAddMemberRequest>({
|
||||
mutationKey: ["organisations", "members", "add"],
|
||||
mutationFn: organisation.addMember,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.organisations.members(variables.organisationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveOrganisationMember() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "members", "remove"],
|
||||
mutationFn: organisation.removeMember,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.organisations.members(variables.organisationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "members", "remove"],
|
||||
mutationFn: organisation.removeMember,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.organisations.members(variables.organisationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOrganisationMemberRole() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "members", "update-role"],
|
||||
mutationFn: organisation.updateMemberRole,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.organisations.members(variables.organisationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
return useMutation({
|
||||
mutationKey: ["organisations", "members", "update-role"],
|
||||
mutationFn: organisation.updateMemberRole,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.organisations.members(variables.organisationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadOrganisationIcon() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<string, Error, { file: File; organisationId: number }>({
|
||||
mutationKey: ["organisations", "upload-icon"],
|
||||
mutationFn: ({ file, organisationId }) => organisation.uploadIcon(file, organisationId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
});
|
||||
return useMutation<string, Error, { file: File; organisationId: number }>({
|
||||
mutationKey: ["organisations", "upload-icon"],
|
||||
mutationFn: ({ file, organisationId }) => organisation.uploadIcon(file, organisationId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import type {
|
||||
ProjectCreateRequest,
|
||||
ProjectRecord,
|
||||
ProjectResponse,
|
||||
ProjectUpdateRequest,
|
||||
ProjectCreateRequest,
|
||||
ProjectRecord,
|
||||
ProjectResponse,
|
||||
ProjectUpdateRequest,
|
||||
} from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { project } from "@/lib/server";
|
||||
|
||||
export function useProjects(organisationId?: number | null) {
|
||||
return useQuery<ProjectResponse[]>({
|
||||
queryKey: queryKeys.projects.byOrganisation(organisationId ?? 0),
|
||||
queryFn: () => project.byOrganisation(organisationId ?? 0),
|
||||
enabled: Boolean(organisationId),
|
||||
});
|
||||
return useQuery<ProjectResponse[]>({
|
||||
queryKey: queryKeys.projects.byOrganisation(organisationId ?? 0),
|
||||
queryFn: () => project.byOrganisation(organisationId ?? 0),
|
||||
enabled: Boolean(organisationId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProjectRecord, Error, ProjectCreateRequest>({
|
||||
mutationKey: ["projects", "create"],
|
||||
mutationFn: project.create,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.projects.byOrganisation(variables.organisationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
return useMutation<ProjectRecord, Error, ProjectCreateRequest>({
|
||||
mutationKey: ["projects", "create"],
|
||||
mutationFn: project.create,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.projects.byOrganisation(variables.organisationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProjectRecord, Error, ProjectUpdateRequest>({
|
||||
mutationKey: ["projects", "update"],
|
||||
mutationFn: project.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
});
|
||||
return useMutation<ProjectRecord, Error, ProjectUpdateRequest>({
|
||||
mutationKey: ["projects", "update"],
|
||||
mutationFn: project.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["projects", "delete"],
|
||||
mutationFn: project.remove,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
});
|
||||
return useMutation({
|
||||
mutationKey: ["projects", "delete"],
|
||||
mutationFn: project.remove,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,45 +4,45 @@ import { queryKeys } from "@/lib/query/keys";
|
||||
import { sprint } from "@/lib/server";
|
||||
|
||||
export function useSprints(projectId?: number | null) {
|
||||
return useQuery<SprintRecord[]>({
|
||||
queryKey: queryKeys.sprints.byProject(projectId ?? 0),
|
||||
queryFn: () => sprint.byProject(projectId ?? 0),
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
return useQuery<SprintRecord[]>({
|
||||
queryKey: queryKeys.sprints.byProject(projectId ?? 0),
|
||||
queryFn: () => sprint.byProject(projectId ?? 0),
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSprint() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<SprintRecord, Error, SprintCreateRequest>({
|
||||
mutationKey: ["sprints", "create"],
|
||||
mutationFn: sprint.create,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(variables.projectId) });
|
||||
},
|
||||
});
|
||||
return useMutation<SprintRecord, Error, SprintCreateRequest>({
|
||||
mutationKey: ["sprints", "create"],
|
||||
mutationFn: sprint.create,
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(variables.projectId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSprint() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<SprintRecord, Error, SprintUpdateRequest>({
|
||||
mutationKey: ["sprints", "update"],
|
||||
mutationFn: sprint.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
|
||||
},
|
||||
});
|
||||
return useMutation<SprintRecord, Error, SprintUpdateRequest>({
|
||||
mutationKey: ["sprints", "update"],
|
||||
mutationFn: sprint.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSprint() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["sprints", "delete"],
|
||||
mutationFn: sprint.remove,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
|
||||
},
|
||||
});
|
||||
return useMutation({
|
||||
mutationKey: ["sprints", "delete"],
|
||||
mutationFn: sprint.remove,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,47 +4,47 @@ import { queryKeys } from "@/lib/query/keys";
|
||||
import { timer } from "@/lib/server";
|
||||
|
||||
export function useTimerState(issueId?: number | null, options?: { refetchInterval?: number }) {
|
||||
return useQuery<TimerState>({
|
||||
queryKey: queryKeys.timers.active(issueId ?? 0),
|
||||
queryFn: () => timer.get(issueId ?? 0),
|
||||
enabled: Boolean(issueId),
|
||||
refetchInterval: options?.refetchInterval,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
return useQuery<TimerState>({
|
||||
queryKey: queryKeys.timers.active(issueId ?? 0),
|
||||
queryFn: () => timer.get(issueId ?? 0),
|
||||
enabled: Boolean(issueId),
|
||||
refetchInterval: options?.refetchInterval,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInactiveTimers(issueId?: number | null, options?: { refetchInterval?: number }) {
|
||||
return useQuery<TimerState[]>({
|
||||
queryKey: queryKeys.timers.inactive(issueId ?? 0),
|
||||
queryFn: () => timer.getInactive(issueId ?? 0),
|
||||
enabled: Boolean(issueId),
|
||||
refetchInterval: options?.refetchInterval,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
return useQuery<TimerState[]>({
|
||||
queryKey: queryKeys.timers.inactive(issueId ?? 0),
|
||||
queryFn: () => timer.getInactive(issueId ?? 0),
|
||||
enabled: Boolean(issueId),
|
||||
refetchInterval: options?.refetchInterval,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTimer() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TimerState, Error, TimerToggleRequest>({
|
||||
mutationKey: ["timers", "toggle"],
|
||||
mutationFn: timer.toggle,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
||||
},
|
||||
});
|
||||
return useMutation<TimerState, Error, TimerToggleRequest>({
|
||||
mutationKey: ["timers", "toggle"],
|
||||
mutationFn: timer.toggle,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEndTimer() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TimerState, Error, TimerEndRequest>({
|
||||
mutationKey: ["timers", "end"],
|
||||
mutationFn: timer.end,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
||||
},
|
||||
});
|
||||
return useMutation<TimerState, Error, TimerEndRequest>({
|
||||
mutationKey: ["timers", "end"],
|
||||
mutationFn: timer.end,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,33 +4,33 @@ import { queryKeys } from "@/lib/query/keys";
|
||||
import { user } from "@/lib/server";
|
||||
|
||||
export function useUserByUsername(username?: string | null) {
|
||||
return useQuery<UserRecord>({
|
||||
queryKey: queryKeys.users.byUsername(username ?? ""),
|
||||
queryFn: () => user.byUsername(username ?? ""),
|
||||
enabled: Boolean(username),
|
||||
});
|
||||
return useQuery<UserRecord>({
|
||||
queryKey: queryKeys.users.byUsername(username ?? ""),
|
||||
queryFn: () => user.byUsername(username ?? ""),
|
||||
enabled: Boolean(username),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<UserRecord, Error, UserUpdateRequest>({
|
||||
mutationKey: ["users", "update"],
|
||||
mutationFn: user.update,
|
||||
onSuccess: (_data) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||
},
|
||||
});
|
||||
return useMutation<UserRecord, Error, UserUpdateRequest>({
|
||||
mutationKey: ["users", "update"],
|
||||
mutationFn: user.update,
|
||||
onSuccess: (_data) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadAvatar() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<string, Error, File>({
|
||||
mutationKey: ["users", "upload-avatar"],
|
||||
mutationFn: user.uploadAvatar,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||
},
|
||||
});
|
||||
return useMutation<string, Error, File>({
|
||||
mutationKey: ["users", "upload-avatar"],
|
||||
mutationFn: user.uploadAvatar,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
// query key factory for granular cache invalidation
|
||||
|
||||
export const queryKeys = {
|
||||
organisations: {
|
||||
all: ["organisations"] as const,
|
||||
byUser: () => [...queryKeys.organisations.all, "by-user"] as const,
|
||||
members: (orgId: number) => [...queryKeys.organisations.all, orgId, "members"] as const,
|
||||
},
|
||||
projects: {
|
||||
all: ["projects"] as const,
|
||||
byOrganisation: (orgId: number) => [...queryKeys.projects.all, "by-org", orgId] as const,
|
||||
},
|
||||
issues: {
|
||||
all: ["issues"] as const,
|
||||
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
|
||||
statusCount: (organisationId: number, status: string) =>
|
||||
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
|
||||
},
|
||||
sprints: {
|
||||
all: ["sprints"] as const,
|
||||
byProject: (projectId: number) => [...queryKeys.sprints.all, "by-project", projectId] as const,
|
||||
},
|
||||
timers: {
|
||||
all: ["timers"] as const,
|
||||
active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const,
|
||||
inactive: (issueId: number) => [...queryKeys.timers.all, "inactive", issueId] as const,
|
||||
list: (issueId: number) => [...queryKeys.timers.all, "list", issueId] as const,
|
||||
},
|
||||
users: {
|
||||
all: ["users"] as const,
|
||||
byUsername: (username: string) => [...queryKeys.users.all, "by-username", username] as const,
|
||||
},
|
||||
organisations: {
|
||||
all: ["organisations"] as const,
|
||||
byUser: () => [...queryKeys.organisations.all, "by-user"] as const,
|
||||
members: (orgId: number) => [...queryKeys.organisations.all, orgId, "members"] as const,
|
||||
},
|
||||
projects: {
|
||||
all: ["projects"] as const,
|
||||
byOrganisation: (orgId: number) => [...queryKeys.projects.all, "by-org", orgId] as const,
|
||||
},
|
||||
issues: {
|
||||
all: ["issues"] as const,
|
||||
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
|
||||
statusCount: (organisationId: number, status: string) =>
|
||||
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
|
||||
},
|
||||
sprints: {
|
||||
all: ["sprints"] as const,
|
||||
byProject: (projectId: number) => [...queryKeys.sprints.all, "by-project", projectId] as const,
|
||||
},
|
||||
timers: {
|
||||
all: ["timers"] as const,
|
||||
active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const,
|
||||
inactive: (issueId: number) => [...queryKeys.timers.all, "inactive", issueId] as const,
|
||||
list: (issueId: number) => [...queryKeys.timers.all, "list", issueId] as const,
|
||||
},
|
||||
users: {
|
||||
all: ["users"] as const,
|
||||
byUsername: (username: string) => [...queryKeys.users.all, "by-username", username] as const,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,28 +8,28 @@ export * as timer from "@/lib/server/timer";
|
||||
export * as user from "@/lib/server/user";
|
||||
|
||||
export async function getErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||
const error = await res.json().catch(() => res.text());
|
||||
if (typeof error === "string") {
|
||||
return error || fallback;
|
||||
const error = await res.json().catch(() => res.text());
|
||||
if (typeof error === "string") {
|
||||
return error || fallback;
|
||||
}
|
||||
if (error && typeof error === "object") {
|
||||
if ("details" in error && error.details) {
|
||||
const messages = Object.values(error.details as Record<string, string[]>).flat();
|
||||
if (messages.length > 0) return messages.join(", ");
|
||||
}
|
||||
if (error && typeof error === "object") {
|
||||
if ("details" in error && error.details) {
|
||||
const messages = Object.values(error.details as Record<string, string[]>).flat();
|
||||
if (messages.length > 0) return messages.join(", ");
|
||||
}
|
||||
if ("error" in error && typeof error.error === "string") {
|
||||
return error.error || fallback;
|
||||
}
|
||||
if ("error" in error && typeof error.error === "string") {
|
||||
return error.error || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function parseError(error: ApiError | string | Error): string {
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message;
|
||||
if (error.details) {
|
||||
const messages = Object.values(error.details).flat();
|
||||
return messages.join(", ");
|
||||
}
|
||||
return error.error;
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message;
|
||||
if (error.details) {
|
||||
const messages = Object.values(error.details).flat();
|
||||
return messages.join(", ");
|
||||
}
|
||||
return error.error;
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function byProject(projectId: number): Promise<IssueResponse[]> {
|
||||
const url = new URL(`${getServerURL()}/issues/by-project`);
|
||||
url.searchParams.set("projectId", `${projectId}`);
|
||||
const url = new URL(`${getServerURL()}/issues/by-project`);
|
||||
url.searchParams.set("projectId", `${projectId}`);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get issues by project (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get issues by project (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,26 +3,26 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function create(request: IssueCreateRequest): Promise<IssueRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/issue/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/issue/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to create issue (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to create issue (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as IssueRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to create issue (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
const data = (await res.json()) as IssueRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to create issue (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function remove(issueId: number): Promise<SuccessResponse> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/issue/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ id: issueId }),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/issue/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ id: issueId }),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to delete issue (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to delete issue (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function replaceStatus(request: IssuesReplaceStatusRequest): Promise<ReplaceStatusResponse> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/issues/replace-status`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/issues/replace-status`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to replace status (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to replace status (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@ import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function statusCount(organisationId: number, status: string): Promise<StatusCountResponse> {
|
||||
const url = new URL(`${getServerURL()}/issues/status-count`);
|
||||
url.searchParams.set("organisationId", `${organisationId}`);
|
||||
url.searchParams.set("status", status);
|
||||
const url = new URL(`${getServerURL()}/issues/status-count`);
|
||||
url.searchParams.set("organisationId", `${organisationId}`);
|
||||
url.searchParams.set("status", status);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get issue status count (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get issue status count (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function update(input: IssueUpdateRequest): Promise<IssueRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/issue/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/issue/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update issue (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update issue (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function addMember(request: OrgAddMemberRequest): Promise<OrganisationMemberRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/organisation/add-member`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/organisation/add-member`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to add member (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to add member (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function byUser(): Promise<OrganisationResponse[]> {
|
||||
const res = await fetch(`${getServerURL()}/organisations/by-user`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/organisations/by-user`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get organisations (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get organisations (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,26 +3,26 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function create(request: OrgCreateRequest): Promise<OrganisationRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/organisation/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/organisation/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to create organisation (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to create organisation (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as OrganisationRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to create organisation (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
const data = (await res.json()) as OrganisationRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to create organisation (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function remove(organisationId: number): Promise<SuccessResponse> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/organisation/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ id: organisationId }),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/organisation/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ id: organisationId }),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to delete organisation (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to delete organisation (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function members(organisationId: number): Promise<OrganisationMemberResponse[]> {
|
||||
const url = new URL(`${getServerURL()}/organisation/members`);
|
||||
url.searchParams.set("organisationId", `${organisationId}`);
|
||||
const url = new URL(`${getServerURL()}/organisation/members`);
|
||||
url.searchParams.set("organisationId", `${organisationId}`);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get members (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get members (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function removeMember(request: OrgRemoveMemberRequest): Promise<SuccessResponse> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/organisation/remove-member`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/organisation/remove-member`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to remove member (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to remove member (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function update(input: OrgUpdateRequest): Promise<OrganisationRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/organisation/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/organisation/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update organisation (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update organisation (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,24 +3,24 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function updateMemberRole(
|
||||
request: OrgUpdateMemberRoleRequest,
|
||||
request: OrgUpdateMemberRoleRequest,
|
||||
): Promise<OrganisationMemberRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/organisation/update-member-role`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/organisation/update-member-role`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update member role (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update member role (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -2,41 +2,41 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function uploadIcon(file: File, organisationId: number): Promise<string> {
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error("File size exceeds 5MB limit");
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error("File size exceeds 5MB limit");
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif");
|
||||
}
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("organisationId", organisationId.toString());
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("organisationId", organisationId.toString());
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
|
||||
const res = await fetch(`${getServerURL()}/organisation/upload-icon`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/organisation/upload-icon`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `Failed to upload icon (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `Failed to upload icon (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.iconURL) {
|
||||
return data.iconURL;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.iconURL) {
|
||||
return data.iconURL;
|
||||
}
|
||||
|
||||
throw new Error("Failed to upload icon");
|
||||
throw new Error("Failed to upload icon");
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function byOrganisation(organisationId: number): Promise<ProjectResponse[]> {
|
||||
const url = new URL(`${getServerURL()}/projects/by-organisation`);
|
||||
url.searchParams.set("organisationId", `${organisationId}`);
|
||||
const url = new URL(`${getServerURL()}/projects/by-organisation`);
|
||||
url.searchParams.set("organisationId", `${organisationId}`);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get projects by organisation (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get projects by organisation (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,26 +3,26 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function create(request: ProjectCreateRequest): Promise<ProjectRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/project/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/project/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to create project (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to create project (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as ProjectRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to create project (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
const data = (await res.json()) as ProjectRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to create project (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function remove(projectId: number): Promise<SuccessResponse> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/project/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ id: projectId }),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/project/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ id: projectId }),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to delete project (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to delete project (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function update(input: ProjectUpdateRequest): Promise<ProjectRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/project/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/project/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update project (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update project (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function byProject(projectId: number): Promise<SprintRecord[]> {
|
||||
const url = new URL(`${getServerURL()}/sprints/by-project`);
|
||||
url.searchParams.set("projectId", `${projectId}`);
|
||||
const url = new URL(`${getServerURL()}/sprints/by-project`);
|
||||
url.searchParams.set("projectId", `${projectId}`);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get sprints (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get sprints (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,29 +3,29 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function create(input: SprintCreateRequest): Promise<SprintRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/sprint/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
name: input.name.trim(),
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/sprint/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
name: input.name.trim(),
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to create sprint (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to create sprint (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as SprintRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to create sprint (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
const data = (await res.json()) as SprintRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to create sprint (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function remove(sprintId: number): Promise<SuccessResponse> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/sprint/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ id: sprintId }),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/sprint/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ id: sprintId }),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to delete sprint (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to delete sprint (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,25 +3,25 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function update(input: SprintUpdateRequest): Promise<SprintRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/sprint/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
name: input.name?.trim(),
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/sprint/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
name: input.name?.trim(),
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update sprint (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update sprint (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function end(request: TimerEndRequest): Promise<TimerState> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/timer/end`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/timer/end`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to end timer (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to end timer (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function get(issueId: number): Promise<TimerState> {
|
||||
const url = new URL(`${getServerURL()}/timer/get`);
|
||||
url.searchParams.set("issueId", `${issueId}`);
|
||||
const url = new URL(`${getServerURL()}/timer/get`);
|
||||
url.searchParams.set("issueId", `${issueId}`);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get timer (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get timer (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@ import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function getInactive(issueId: number): Promise<TimerState[]> {
|
||||
const url = new URL(`${getServerURL()}/timer/get-inactive`);
|
||||
url.searchParams.set("issueId", `${issueId}`);
|
||||
const url = new URL(`${getServerURL()}/timer/get-inactive`);
|
||||
url.searchParams.set("issueId", `${issueId}`);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as TimerState[];
|
||||
return data ?? [];
|
||||
const data = (await res.json()) as TimerState[];
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
@@ -2,28 +2,28 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
type TimerListInput = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function list(input: TimerListInput = {}): Promise<unknown> {
|
||||
const url = new URL(`${getServerURL()}/timers`);
|
||||
if (input.limit != null) url.searchParams.set("limit", `${input.limit}`);
|
||||
if (input.offset != null) url.searchParams.set("offset", `${input.offset}`);
|
||||
const url = new URL(`${getServerURL()}/timers`);
|
||||
if (input.limit != null) url.searchParams.set("limit", `${input.limit}`);
|
||||
if (input.offset != null) url.searchParams.set("offset", `${input.offset}`);
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(url.toString(), {
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function toggle(request: TimerToggleRequest): Promise<TimerState> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/timer/toggle`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/timer/toggle`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to toggle timer (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to toggle timer (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function byUsername(username: string): Promise<UserRecord> {
|
||||
const url = new URL(`${getServerURL()}/user/by-username`);
|
||||
url.searchParams.set("username", username);
|
||||
const url = new URL(`${getServerURL()}/user/by-username`);
|
||||
url.searchParams.set("username", username);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get user (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get user (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,26 +3,26 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function update(request: UserUpdateRequest): Promise<UserRecord> {
|
||||
const csrfToken = getCsrfToken();
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/user/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/user/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update user (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to update user (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as UserRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to update user (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
const data = (await res.json()) as UserRecord;
|
||||
if (!data.id) {
|
||||
throw new Error(`failed to update user (${res.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -2,40 +2,40 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function uploadAvatar(file: File): Promise<string> {
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error("File size exceeds 5MB limit");
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error("File size exceeds 5MB limit");
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif");
|
||||
}
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
const csrfToken = getCsrfToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
||||
|
||||
const res = await fetch(`${getServerURL()}/user/upload-avatar`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`${getServerURL()}/user/upload-avatar`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `Failed to upload avatar (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `Failed to upload avatar (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.avatarURL) {
|
||||
return data.avatarURL;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.avatarURL) {
|
||||
return data.avatarURL;
|
||||
}
|
||||
|
||||
throw new Error("Failed to upload avatar");
|
||||
throw new Error("Failed to upload avatar");
|
||||
}
|
||||
|
||||
@@ -2,63 +2,63 @@ import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function issueID(key: string, num: number) {
|
||||
return `${key}-${num.toString().padStart(3, "0")}`;
|
||||
return `${key}-${num.toString().padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
export function getCsrfToken(): string | null {
|
||||
return sessionStorage.getItem("csrfToken");
|
||||
return sessionStorage.getItem("csrfToken");
|
||||
}
|
||||
|
||||
export function setCsrfToken(token: string): void {
|
||||
sessionStorage.setItem("csrfToken", token);
|
||||
sessionStorage.setItem("csrfToken", token);
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
sessionStorage.removeItem("csrfToken");
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("selectedOrganisationId");
|
||||
localStorage.removeItem("selectedProjectId");
|
||||
sessionStorage.removeItem("csrfToken");
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("selectedOrganisationId");
|
||||
localStorage.removeItem("selectedProjectId");
|
||||
}
|
||||
|
||||
export function capitalise(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
const ENV_SERVER_URL = import.meta.env.VITE_SERVER_URL?.trim();
|
||||
|
||||
export function getServerURL() {
|
||||
let serverURL =
|
||||
localStorage.getItem("serverURL") || // user-defined server URL
|
||||
ENV_SERVER_URL || // environment variable
|
||||
"https://tnirps.ob248.com"; // fallback
|
||||
if (serverURL.endsWith("/")) {
|
||||
serverURL = serverURL.slice(0, -1);
|
||||
}
|
||||
return serverURL;
|
||||
let serverURL =
|
||||
localStorage.getItem("serverURL") || // user-defined server URL
|
||||
ENV_SERVER_URL || // environment variable
|
||||
"https://tnirps.ob248.com"; // fallback
|
||||
if (serverURL.endsWith("/")) {
|
||||
serverURL = serverURL.slice(0, -1);
|
||||
}
|
||||
return serverURL;
|
||||
}
|
||||
|
||||
export function formatTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export const DARK_TEXT_COLOUR = "#0a0a0a";
|
||||
const THRESHOLD = 0.6;
|
||||
|
||||
export const isLight = (hex: string): boolean => {
|
||||
const num = Number.parseInt(hex.replace("#", ""), 16);
|
||||
const r = (num >> 16) & 255;
|
||||
const g = (num >> 8) & 255;
|
||||
const b = num & 255;
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > THRESHOLD;
|
||||
const num = Number.parseInt(hex.replace("#", ""), 16);
|
||||
const r = (num >> 16) & 255;
|
||||
const g = (num >> 8) & 255;
|
||||
const b = num & 255;
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > THRESHOLD;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user