frontend indentation set to 2

This commit is contained in:
Oliver Bryan
2026-01-21 17:47:04 +00:00
parent 70504b3056
commit 5a5e40659c
117 changed files with 7548 additions and 7785 deletions

View File

@@ -1,19 +1,19 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 4,
"lineWidth": 110
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"files": {
"includes": ["**", "!dist", "!src-tauri/target", "!src-tauri/gen"]
"root": false,
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 110
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"files": {
"includes": ["**", "!dist", "!src-tauri/target", "!src-tauri/gen"]
}
}

View File

@@ -1,22 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/App.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/App.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -1,55 +1,55 @@
{
"name": "@sprint/frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"host": "NODE_ENV=production vite --host",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "export __NV_DISABLE_EXPLICIT_SYNC=1 && tauri dev"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@nsmr/pixelart-react": "^2.0.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@sprint/shared": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query-devtools": "^5.91.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.13.0",
"react-dom": "^19.1.0",
"react-resizable-panels": "^4.0.15",
"react-router-dom": "^7.10.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/node": "^25.0.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
"name": "@sprint/frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"host": "NODE_ENV=production vite --host",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "export __NV_DISABLE_EXPLICIT_SYNC=1 && tauri dev"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@nsmr/pixelart-react": "^2.0.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@sprint/shared": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query-devtools": "^5.91.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.13.0",
"react-dom": "^19.1.0",
"react-resizable-panels": "^4.0.15",
"react-router-dom": "^7.10.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/node": "^25.0.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": ["core:default", "opener:default"]
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": ["core:default", "opener:default"]
}

View File

@@ -1,32 +1,32 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "sprint",
"version": "0.1.0",
"identifier": "com.hex248.sprint",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420/app",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Sprint",
"width": 1600,
"height": 900,
"minWidth": 640,
"minHeight": 360,
"decorations": false,
"url": "/app"
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all"
"$schema": "https://schema.tauri.app/config/2",
"productName": "sprint",
"version": "0.1.0",
"identifier": "com.hex248.sprint",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420/app",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Sprint",
"width": 1600,
"height": 900,
"minWidth": 640,
"minHeight": 360,
"decorations": false,
"url": "/app"
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all"
}
}

View File

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

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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,
)}
</>
);
}

View File

@@ -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}</>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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>
);
}

View File

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

View File

@@ -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}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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,
},
},
});

View File

@@ -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],
);
}

View File

@@ -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 });
},
});
}

View File

@@ -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() });
},
});
}

View File

@@ -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 });
},
});
}

View File

@@ -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 });
},
});
}

View File

@@ -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) });
},
});
}

View File

@@ -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 });
},
});
}

View File

@@ -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,
},
};

View File

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

View File

@@ -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();
}

View File

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

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

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

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

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

View File

@@ -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();
}

View File

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

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

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

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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 ?? [];
}

View File

@@ -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();
}

Some files were not shown because too many files have changed in this diff Show More