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, "root": false,
"$schema": "https://biomejs.dev/schemas/latest/schema.json", "$schema": "https://biomejs.dev/schemas/latest/schema.json",
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"formatWithErrors": false, "formatWithErrors": false,
"indentStyle": "space", "indentStyle": "space",
"indentWidth": 4, "indentWidth": 2,
"lineWidth": 110 "lineWidth": 110
}, },
"css": { "css": {
"parser": { "parser": {
"tailwindDirectives": true "tailwindDirectives": true
}
},
"files": {
"includes": ["**", "!dist", "!src-tauri/target", "!src-tauri/gen"]
} }
},
"files": {
"includes": ["**", "!dist", "!src-tauri/target", "!src-tauri/gen"]
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,220 +7,220 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px); --radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px); --radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px); --radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-personality: var(--personality); --color-personality: var(--personality);
} }
:root { :root {
--font-weight: 450; --font-weight: 450;
--radius: 0.625rem; --radius: 0.625rem;
--personality: #f26d77; --personality: #f26d77;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(73.802% 0.00008 271.152); --border: oklch(73.802% 0.00008 271.152);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
--font-weight: 400; --font-weight: 400;
--personality: #f26d77; --personality: #f26d77;
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.105 0 0); --popover: oklch(0.105 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(100% 0.00011 271.152 / 0.22); --border: oklch(100% 0.00011 271.152 / 0.22);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
::selection { ::selection {
background-color: var(--personality); background-color: var(--personality);
color: var(--background); color: var(--background);
} }
} }
* { * {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-optical-sizing: auto; font-optical-sizing: auto;
font-weight: var(--font-weight, 400); font-weight: var(--font-weight, 400);
font-style: normal; font-style: normal;
font-variant-ligatures: none; font-variant-ligatures: none;
} }
.font-200 { .font-200 {
font-weight: 200; font-weight: 200;
} }
.font-250 { .font-250 {
font-weight: 250; font-weight: 250;
} }
.font-300 { .font-300 {
font-weight: 300; font-weight: 300;
} }
.font-350 { .font-350 {
font-weight: 350; font-weight: 350;
} }
.font-400 { .font-400 {
font-weight: 400; font-weight: 400;
} }
.font-450 { .font-450 {
font-weight: 450; font-weight: 450;
} }
.font-500 { .font-500 {
font-weight: 500; font-weight: 500;
} }
.font-550 { .font-550 {
font-weight: 550; font-weight: 550;
} }
.font-600 { .font-600 {
font-weight: 600; font-weight: 600;
} }
.font-650 { .font-650 {
font-weight: 650; font-weight: 650;
} }
.font-700 { .font-700 {
font-weight: 700; font-weight: 700;
} }
.noselect { .noselect {
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }
/* react-colorful */ /* react-colorful */
.react-colorful { .react-colorful {
width: 150px !important; width: 150px !important;
height: 150px !important; height: 150px !important;
} }
.react-colorful__saturation { .react-colorful__saturation {
border-radius: 0 !important; border-radius: 0 !important;
/* cursor: pointer; */ /* cursor: pointer; */
} }
.react-colorful__last-control { .react-colorful__last-control {
border-radius: 0 !important; border-radius: 0 !important;
} }
.react-colorful__saturation-pointer { .react-colorful__saturation-pointer {
width: 16px !important; width: 16px !important;
height: 16px !important; height: 16px !important;
border: 1px solid white !important; border: 1px solid white !important;
} }
.react-colorful__hue { .react-colorful__hue {
height: 10px !important; height: 10px !important;
cursor: pointer; cursor: pointer;
} }
.react-colorful__hue-pointer { .react-colorful__hue-pointer {
width: 16px !important; width: 16px !important;
height: 16px !important; height: 16px !important;
border: 1px solid white !important; border: 1px solid white !important;
} }
[data-sonner-toast] { [data-sonner-toast] {
transition: none !important; transition: none !important;
padding: 10px 15px 10px 15px !important; padding: 10px 15px 10px 15px !important;
width: max-content !important; width: max-content !important;
max-width: 90vw !important; max-width: 90vw !important;
display: inline-flex !important; display: inline-flex !important;
align-items: center !important; align-items: center !important;
white-space: nowrap !important; white-space: nowrap !important;
} }

View File

@@ -16,163 +16,160 @@ import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Account({ trigger }: { trigger?: ReactNode }) { function Account({ trigger }: { trigger?: ReactNode }) {
const { user: currentUser, setUser } = useAuthenticatedSession(); const { user: currentUser, setUser } = useAuthenticatedSession();
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [avatarURL, setAvatarUrl] = useState<string | null>(null); const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [iconPreference, setIconPreference] = useState<IconStyle>("lucide"); const [iconPreference, setIconPreference] = useState<IconStyle>("lucide");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setName(currentUser.name); setName(currentUser.name);
setUsername(currentUser.username); setUsername(currentUser.username);
setAvatarUrl(currentUser.avatarURL || null); setAvatarUrl(currentUser.avatarURL || null);
setIconPreference((currentUser.iconPreference as IconStyle) ?? "lucide"); setIconPreference((currentUser.iconPreference as IconStyle) ?? "lucide");
setPassword(""); setPassword("");
setError(""); setError("");
setSubmitAttempted(false); setSubmitAttempted(false);
}, [open, currentUser]); }, [open, currentUser]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSubmitAttempted(true); setSubmitAttempted(true);
if (name.trim() === "") { if (name.trim() === "") {
return; return;
} }
try { try {
const data = await updateUser.mutateAsync({ const data = await updateUser.mutateAsync({
name: name.trim(), name: name.trim(),
password: password.trim() || undefined, password: password.trim() || undefined,
avatarURL, avatarURL,
iconPreference, iconPreference,
}); });
setError(""); setError("");
setUser(data); setUser(data);
setPassword(""); setPassword("");
setOpen(false); setOpen(false);
toast.success("Account updated successfully", { toast.success("Account updated successfully", {
dismissible: false, dismissible: false,
}); });
} catch (err) { } catch (err) {
const message = parseError(err as Error); const message = parseError(err as Error);
setError(message); setError(message);
toast.error(`Error updating account: ${message}`, { toast.error(`Error updating account: ${message}`, {
dismissible: false, dismissible: false,
}); });
} }
}; };
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger || ( {trigger || (
<Button variant="ghost" className="flex w-full justify-end px-2 py-1 m-0 h-auto"> <Button variant="ghost" className="flex w-full justify-end px-2 py-1 m-0 h-auto">
My Account My Account
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className={cn("sm:max-w-sm", error !== "" && "border border-destructive")}> <DialogContent className={cn("sm:max-w-sm", error !== "" && "border border-destructive")}>
<DialogHeader> <DialogHeader>
<DialogTitle>Account</DialogTitle> <DialogTitle>Account</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-2"> <form onSubmit={handleSubmit} className="flex flex-col gap-2">
<UploadAvatar <UploadAvatar
name={name} name={name}
username={username} username={username}
avatarURL={avatarURL} avatarURL={avatarURL}
onAvatarUploaded={setAvatarUrl} onAvatarUploaded={setAvatarUrl}
/> />
{avatarURL && ( {avatarURL && (
<Button <Button
variant={"dummy"} variant={"dummy"}
type={"button"} type={"button"}
onClick={() => { onClick={() => {
setAvatarUrl(null); setAvatarUrl(null);
}} }}
className="-mt-2 hover:text-personality" className="-mt-2 hover:text-personality"
> >
Remove Avatar Remove Avatar
</Button> </Button>
)} )}
<Field <Field
label="Full Name" label="Full Name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
submitAttempted={submitAttempted} submitAttempted={submitAttempted}
/> />
<Field <Field
label="Password" label="Password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Leave empty to keep current password" placeholder="Leave empty to keep current password"
hidden={true} hidden={true}
/> />
<Label className="text-lg -mt-2">Preferences</Label> <Label className="text-lg -mt-2">Preferences</Label>
<div className="flex gap-8 justify w-full"> <div className="flex gap-8 justify w-full">
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<Label className="text-sm">Light/Dark Mode</Label> <Label className="text-sm">Light/Dark Mode</Label>
<ThemeToggle withText /> <ThemeToggle withText />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<Label className="text-sm">Icon Style</Label> <Label className="text-sm">Icon Style</Label>
<Select <Select value={iconPreference} onValueChange={(v) => setIconPreference(v as IconStyle)}>
value={iconPreference} <SelectTrigger className="w-full">
onValueChange={(v) => setIconPreference(v as IconStyle)} <SelectValue />
> </SelectTrigger>
<SelectTrigger className="w-full"> <SelectContent position="popper" side="bottom" align="start">
<SelectValue /> <SelectItem value="lucide">
</SelectTrigger> <div className="flex items-center gap-2">
<SelectContent position="popper" side="bottom" align="start"> <Icon icon="sun" iconStyle="lucide" size={16} />
<SelectItem value="lucide"> 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> </div>
</SelectItem>
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>} <SelectItem value="pixel">
<div className="flex items-center gap-2">
<div className="flex justify-end mt-4"> <Icon icon="sun" iconStyle="pixel" size={16} />
<Button variant={"outline"} type={"submit"} className="px-12"> Pixel
Save
</Button>
</div> </div>
</form> </SelectItem>
</DialogContent> <SelectItem value="phosphor">
</Dialog> <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; export default Account;

View File

@@ -3,124 +3,121 @@ import { type FormEvent, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { useAddOrganisationMember } from "@/lib/query/hooks"; import { useAddOrganisationMember } from "@/lib/query/hooks";
import { parseError, user } from "@/lib/server"; import { parseError, user } from "@/lib/server";
export function AddMember({ export function AddMember({
organisationId, organisationId,
existingMembers, existingMembers,
trigger, trigger,
onSuccess, onSuccess,
}: { }: {
organisationId: number; organisationId: number;
existingMembers: string[]; existingMembers: string[];
trigger?: React.ReactNode; trigger?: React.ReactNode;
onSuccess?: (user: UserRecord) => void | Promise<void>; onSuccess?: (user: UserRecord) => void | Promise<void>;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const addMember = useAddOrganisationMember(); const addMember = useAddOrganisationMember();
const reset = () => { const reset = () => {
setUsername(""); setUsername("");
setSubmitAttempted(false); setSubmitAttempted(false);
setSubmitting(false); setSubmitting(false);
setError(null); setError(null);
}; };
const onOpenChange = (nextOpen: boolean) => { const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen); setOpen(nextOpen);
if (!nextOpen) { if (!nextOpen) {
reset(); reset();
} }
}; };
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setSubmitAttempted(true); setSubmitAttempted(true);
if (username.trim() === "") { if (username.trim() === "") {
return; return;
} }
if (existingMembers.includes(username)) { if (existingMembers.includes(username)) {
setError("user is already a member of this organisation"); setError("user is already a member of this organisation");
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
const userData: UserRecord = await user.byUsername(username); const userData: UserRecord = await user.byUsername(username);
const userId = userData.id; const userId = userData.id;
await addMember.mutateAsync({ organisationId, userId, role: "member" }); await addMember.mutateAsync({ organisationId, userId, role: "member" });
setOpen(false); setOpen(false);
reset(); reset();
try { try {
await onSuccess?.(userData); await onSuccess?.(userData);
} catch (actionErr) { } catch (actionErr) {
console.error(actionErr); console.error(actionErr);
} }
} catch (err) { } catch (err) {
const message = parseError(err as Error); const message = parseError(err as Error);
console.error(err); console.error(err);
setError(message || "failed to add member"); setError(message || "failed to add member");
setSubmitting(false); setSubmitting(false);
toast.error(`Error adding member: ${message}`, { toast.error(`Error adding member: ${message}`, {
dismissible: false, dismissible: false,
}); });
} }
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{trigger || <Button variant="outline">Add Member</Button>}</DialogTrigger> <DialogTrigger asChild>{trigger || <Button variant="outline">Add Member</Button>}</DialogTrigger>
<DialogContent className={"w-md"}> <DialogContent className={"w-md"}>
<DialogHeader> <DialogHeader>
<DialogTitle>Add Member</DialogTitle> <DialogTitle>Add Member</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid mt-2"> <div className="grid mt-2">
<Field <Field
label="Username" label="Username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
submitAttempted={submitAttempted} submitAttempted={submitAttempted}
placeholder="Enter username" placeholder="Enter username"
error={error || undefined} error={error || undefined}
/> />
<div className="flex gap-2 w-full justify-end mt-2"> <div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline" type="button"> <Button variant="outline" type="button">
Cancel Cancel
</Button> </Button>
</DialogClose> </DialogClose>
<Button <Button type="submit" disabled={submitting || (username.trim() === "" && submitAttempted)}>
type="submit" {submitting ? "Adding..." : "Add"}
disabled={submitting || (username.trim() === "" && submitAttempted)} </Button>
> </div>
{submitting ? "Adding..." : "Add"} </div>
</Button> </form>
</div> </DialogContent>
</div> </Dialog>
</form> );
</DialogContent>
</Dialog>
);
} }

View File

@@ -3,94 +3,94 @@ import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const FALLBACK_COLOURS = [ const FALLBACK_COLOURS = [
"bg-teal-500", "bg-teal-500",
"bg-rose-500", "bg-rose-500",
"bg-indigo-500", "bg-indigo-500",
"bg-amber-500", "bg-amber-500",
"bg-cyan-500", "bg-cyan-500",
"bg-purple-500", "bg-purple-500",
"bg-lime-500", "bg-lime-500",
"bg-orange-500", "bg-orange-500",
"bg-sky-500", "bg-sky-500",
"bg-fuchsia-500", "bg-fuchsia-500",
"bg-green-500", "bg-green-500",
"bg-red-500", "bg-red-500",
"bg-violet-500", "bg-violet-500",
"bg-yellow-500", "bg-yellow-500",
"bg-blue-500", "bg-blue-500",
"bg-emerald-500", "bg-emerald-500",
"bg-pink-500", "bg-pink-500",
]; ];
function hashStringToIndex(value: string, modulo: number) { function hashStringToIndex(value: string, modulo: number) {
let hash = 0; let hash = 0;
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0; hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
} }
return modulo === 0 ? 0 : hash % modulo; return modulo === 0 ? 0 : hash % modulo;
} }
function getInitials(username: string) { function getInitials(username: string) {
username = username.trim(); username = username.trim();
const parts = username.split(/[^a-zA-Z0-9]+/).filter(Boolean); const parts = username.split(/[^a-zA-Z0-9]+/).filter(Boolean);
if (parts.length === 0) return username.slice(0, 2).toUpperCase(); 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({ export default function Avatar({
avatarURL: _avatarURL, avatarURL: _avatarURL,
name: _name, name: _name,
username, username,
size, size,
textClass = "text-xs", textClass = "text-xs",
strong = false, strong = false,
className, className,
}: { }: {
avatarURL?: string | null; avatarURL?: string | null;
name?: string; name?: string;
username?: string; username?: string;
size?: number; size?: number;
textClass?: string; textClass?: string;
strong?: boolean; strong?: boolean;
className?: string; className?: string;
}) { }) {
// if the username matches the authed user, use their avatarURL and name (avoid stale data) // if the username matches the authed user, use their avatarURL and name (avoid stale data)
const { user } = useSession(); const { user } = useSession();
const avatarURL = !strong && username && user && username === user.username ? user.avatarURL : _avatarURL; const avatarURL = !strong && username && user && username === user.username ? user.avatarURL : _avatarURL;
const name = !strong && username && user && username === user.username ? user.name : _name; const name = !strong && username && user && username === user.username ? user.name : _name;
const backgroundClass = username const backgroundClass = username
? FALLBACK_COLOURS[hashStringToIndex(username, FALLBACK_COLOURS.length)] ? FALLBACK_COLOURS[hashStringToIndex(username, FALLBACK_COLOURS.length)]
: "bg-muted"; : "bg-muted";
return ( return (
<div <div
className={cn( className={cn(
"flex items-center justify-center rounded-full", "flex items-center justify-center rounded-full",
"text-white font-medium select-none", "text-white font-medium select-none",
name && "border", name && "border",
!avatarURL && backgroundClass, !avatarURL && backgroundClass,
"transition-colors", "transition-colors",
`w-${size || 6}`, `w-${size || 6}`,
`h-${size || 6}`, `h-${size || 6}`,
className, className,
)} )}
> >
{avatarURL ? ( {avatarURL ? (
<img <img
src={avatarURL} src={avatarURL}
alt="Avatar" alt="Avatar"
className={`rounded-full object-cover w-${size || 6} h-${size || 6}`} className={`rounded-full object-cover w-${size || 6} h-${size || 6}`}
/> />
) : name ? ( ) : name ? (
<span className={textClass}>{getInitials(name)}</span> <span className={textClass}>{getInitials(name)}</span>
) : ( ) : (
<Icon icon="userRound" className={"size-10"} /> <Icon icon="userRound" className={"size-10"} />
)} )}
</div> </div>
); );
} }

View File

@@ -2,37 +2,37 @@ import { useMemo } from "react";
import { IssueDetails } from "@/components/issue-details"; import { IssueDetails } from "@/components/issue-details";
import { useSelection } from "@/components/selection-provider"; import { useSelection } from "@/components/selection-provider";
import { import {
useOrganisationMembers, useOrganisationMembers,
useSelectedIssue, useSelectedIssue,
useSelectedOrganisation, useSelectedOrganisation,
useSelectedProject, useSelectedProject,
useSprints, useSprints,
} from "@/lib/query/hooks"; } from "@/lib/query/hooks";
export function IssueDetailPane() { export function IssueDetailPane() {
const { selectIssue } = useSelection(); const { selectIssue } = useSelection();
const selectedOrganisation = useSelectedOrganisation(); const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject(); const selectedProject = useSelectedProject();
const issueData = useSelectedIssue(); const issueData = useSelectedIssue();
const { data: sprints = [] } = useSprints(selectedProject?.Project.id); const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
const members = useMemo(() => membersData.map((member) => member.User), [membersData]); const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const statuses = selectedOrganisation?.Organisation.statuses ?? {};
if (!issueData || !selectedProject || !selectedOrganisation) { if (!issueData || !selectedProject || !selectedOrganisation) {
return null; return null;
} }
return ( return (
<IssueDetails <IssueDetails
issueData={issueData} issueData={issueData}
projectKey={selectedProject.Project.key} projectKey={selectedProject.Project.key}
sprints={sprints} sprints={sprints}
members={members} members={members}
statuses={statuses} statuses={statuses}
onClose={() => selectIssue(null)} onClose={() => selectIssue(null)}
onDelete={() => selectIssue(null)} onDelete={() => selectIssue(null)}
/> />
); );
} }

View File

@@ -22,426 +22,418 @@ import { parseError } from "@/lib/server";
import { cn, issueID } from "@/lib/utils"; import { cn, issueID } from "@/lib/utils";
function assigneesToStringArray(assignees: { id: number }[]): string[] { function assigneesToStringArray(assignees: { id: number }[]): string[] {
if (assignees.length === 0) return ["unassigned"]; if (assignees.length === 0) return ["unassigned"];
return assignees.map((a) => a.id.toString()); return assignees.map((a) => a.id.toString());
} }
function stringArrayToAssigneeIds(assigneeIds: string[]): number[] { 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({ export function IssueDetails({
issueData, issueData,
projectKey, projectKey,
sprints, sprints,
members, members,
statuses, statuses,
onClose, onClose,
onDelete, onDelete,
showHeader = true, showHeader = true,
}: { }: {
issueData: IssueResponse; issueData: IssueResponse;
projectKey: string; projectKey: string;
sprints: SprintRecord[]; sprints: SprintRecord[];
members: UserRecord[]; members: UserRecord[];
statuses: Record<string, string>; statuses: Record<string, string>;
onClose: () => void; onClose: () => void;
onDelete?: () => void; onDelete?: () => void;
showHeader?: boolean; showHeader?: boolean;
}) { }) {
const { user } = useSession(); const { user } = useSession();
const updateIssue = useUpdateIssue(); const updateIssue = useUpdateIssue();
const deleteIssue = useDeleteIssue(); const deleteIssue = useDeleteIssue();
const [assigneeIds, setAssigneeIds] = useState<string[]>([]); const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
const [sprintId, setSprintId] = useState<string>("unassigned"); const [sprintId, setSprintId] = useState<string>("unassigned");
const [status, setStatus] = useState<string>(""); const [status, setStatus] = useState<string>("");
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const copyTimeoutRef = useRef<number | null>(null); const copyTimeoutRef = useRef<number | null>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [originalTitle, setOriginalTitle] = useState(""); const [originalTitle, setOriginalTitle] = useState("");
const [isSavingTitle, setIsSavingTitle] = useState(false); const [isSavingTitle, setIsSavingTitle] = useState(false);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [originalDescription, setOriginalDescription] = useState(""); const [originalDescription, setOriginalDescription] = useState("");
const [isEditingDescription, setIsEditingDescription] = useState(false); const [isEditingDescription, setIsEditingDescription] = useState(false);
const [isSavingDescription, setIsSavingDescription] = useState(false); const [isSavingDescription, setIsSavingDescription] = useState(false);
const descriptionRef = useRef<HTMLTextAreaElement>(null); const descriptionRef = useRef<HTMLTextAreaElement>(null);
const isAssignee = assigneeIds.some((id) => user?.id === Number(id)); const isAssignee = assigneeIds.some((id) => user?.id === Number(id));
const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned"); const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned");
const hasMultipleAssignees = actualAssigneeIds.length > 1; const hasMultipleAssignees = actualAssigneeIds.length > 1;
useEffect(() => { useEffect(() => {
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
setAssigneeIds(assigneesToStringArray(issueData.Assignees)); setAssigneeIds(assigneesToStringArray(issueData.Assignees));
setStatus(issueData.Issue.status); setStatus(issueData.Issue.status);
setTitle(issueData.Issue.title); setTitle(issueData.Issue.title);
setOriginalTitle(issueData.Issue.title); setOriginalTitle(issueData.Issue.title);
setDescription(issueData.Issue.description); setDescription(issueData.Issue.description);
setOriginalDescription(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); setIsEditingDescription(false);
}, [issueData]); }
return;
}
useEffect(() => { setIsSavingDescription(true);
return () => { try {
if (copyTimeoutRef.current) { await updateIssue.mutateAsync({
window.clearTimeout(copyTimeoutRef.current); 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) => { const handleConfirmDelete = async () => {
setSprintId(value); try {
const newSprintId = value === "unassigned" ? null : Number(value); 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 { return (
await updateIssue.mutateAsync({ <div className="flex flex-col">
id: issueData.Issue.id, {showHeader && (
sprintId: newSprintId, <div className="flex flex-row items-center justify-end border-b h-[25px]">
}); <span className="w-full">
toast.success( <p className="text-sm w-fit px-1 font-700">{issueID(projectKey, issueData.Issue.number)}</p>
<> </span>
Successfully updated sprint to{" "} <div className="flex items-center">
{value === "unassigned" ? ( <IconButton onClick={handleCopyLink} title={linkCopied ? "Copied" : "Copy link"}>
"Unassigned" {linkCopied ? <Icon icon="check" /> : <Icon icon="link" />}
) : ( </IconButton>
<SmallSprintDisplay sprint={sprints.find((s) => s.id === newSprintId)} /> <IconButton variant="destructive" onClick={handleDelete} title={"Delete issue"}>
)}{" "} <Icon icon="trash" />
for {issueID(projectKey, issueData.Issue.number)} </IconButton>
</>, <IconButton onClick={onClose} title={"Close"}>
{ <Icon icon="x" />
dismissible: false, </IconButton>
}, </div>
);
} 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>
</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 StatusTag from "@/components/status-tag";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { import {
useCreateIssue, useCreateIssue,
useOrganisationMembers, useOrganisationMembers,
useSelectedOrganisation, useSelectedOrganisation,
useSelectedProject, useSelectedProject,
useSprints, useSprints,
} from "@/lib/query/hooks"; } from "@/lib/query/hooks";
import { parseError } from "@/lib/server"; import { parseError } from "@/lib/server";
import { cn, issueID } from "@/lib/utils"; import { cn, issueID } from "@/lib/utils";
export function IssueForm({ trigger }: { trigger?: React.ReactNode }) { export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const selectedOrganisation = useSelectedOrganisation(); const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject(); const selectedProject = useSelectedProject();
const { data: sprints = [] } = useSprints(selectedProject?.Project.id); const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
const createIssue = useCreateIssue(); const createIssue = useCreateIssue();
const members = useMemo(() => membersData.map((member) => member.User), [membersData]); const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const statusOptions = useMemo(() => Object.keys(statuses), [statuses]); const statusOptions = useMemo(() => Object.keys(statuses), [statuses]);
const defaultStatus = statusOptions[0] ?? ""; const defaultStatus = statusOptions[0] ?? "";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [sprintId, setSprintId] = useState<string>("unassigned"); const [sprintId, setSprintId] = useState<string>("unassigned");
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]); const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
const [status, setStatus] = useState<string>(defaultStatus); const [status, setStatus] = useState<string>(defaultStatus);
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const reset = () => { const reset = () => {
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setSprintId("unassigned"); setSprintId("unassigned");
setAssigneeIds(["unassigned"]); setAssigneeIds(["unassigned"]);
setStatus(defaultStatus); setStatus(defaultStatus);
setSubmitAttempted(false); setSubmitAttempted(false);
setSubmitting(false); setSubmitting(false);
setError(null); setError(null);
}; };
const onOpenChange = (nextOpen: boolean) => { const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen); setOpen(nextOpen);
if (!nextOpen) { if (!nextOpen) {
reset(); reset();
} }
}; };
const handleSubmit = async (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
setSubmitAttempted(true); setSubmitAttempted(true);
if ( if (
title.trim() === "" || title.trim() === "" ||
description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH || description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH ||
title.trim().length > ISSUE_TITLE_MAX_LENGTH title.trim().length > ISSUE_TITLE_MAX_LENGTH
) { ) {
return; return;
} }
if (!user.id) { if (!user.id) {
setError("you must be logged in to create an issue"); setError("you must be logged in to create an issue");
return; return;
} }
if (!selectedProject) { if (!selectedProject) {
setError("select a project first"); setError("select a project first");
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
const data = await createIssue.mutateAsync({ const data = await createIssue.mutateAsync({
projectId: selectedProject.Project.id, projectId: selectedProject.Project.id,
title, title,
description, description,
sprintId: sprintId === "unassigned" ? null : Number(sprintId), sprintId: sprintId === "unassigned" ? null : Number(sprintId),
assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)), assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)),
status: status.trim() === "" ? undefined : status, status: status.trim() === "" ? undefined : status,
}); });
setOpen(false); setOpen(false);
reset(); reset();
toast.success(`Created ${issueID(selectedProject.Project.key, data.number)}`, { toast.success(`Created ${issueID(selectedProject.Project.key, data.number)}`, {
dismissible: false, dismissible: false,
}); });
} catch (err) { } catch (err) {
const message = parseError(err as Error); const message = parseError(err as Error);
setError(message); setError(message);
setSubmitting(false); setSubmitting(false);
toast.error(`Error creating issue: ${message}`, { toast.error(`Error creating issue: ${message}`, {
dismissible: false, dismissible: false,
}); });
} }
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger || ( {trigger || (
<Button variant="outline" disabled={!selectedProject}> <Button variant="outline" disabled={!selectedProject}>
Create Issue Create Issue
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className={cn("w-md", error && "border-destructive")}> <DialogContent className={cn("w-md", error && "border-destructive")}>
<DialogHeader> <DialogHeader>
<DialogTitle>Create Issue</DialogTitle> <DialogTitle>Create Issue</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid"> <div className="grid">
{statusOptions.length > 0 && ( {statusOptions.length > 0 && (
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Label>Status</Label> <Label>Status</Label>
<StatusSelect <StatusSelect
statuses={statuses} statuses={statuses}
value={status} value={status}
onChange={(newValue) => { onChange={(newValue) => {
if (newValue.trim() === "") return; if (newValue.trim() === "") return;
setStatus(newValue); setStatus(newValue);
}} }}
trigger={({ isOpen, value }) => ( trigger={({ isOpen, value }) => (
<SelectTrigger <SelectTrigger
className="group flex items-center w-min" className="group flex items-center w-min"
variant="unstyled" variant="unstyled"
chevronClassName="hidden" chevronClassName="hidden"
isOpen={isOpen} isOpen={isOpen}
> >
<StatusTag <StatusTag status={value} colour={statuses[value]} className="hover:opacity-85" />
status={value} </SelectTrigger>
colour={statuses[value]} )}
className="hover:opacity-85" />
/> </div>
</SelectTrigger> )}
)}
/>
</div>
)}
<Field <Field
label="Title" label="Title"
value={title} value={title}
onChange={(event) => setTitle(event.target.value)} onChange={(event) => setTitle(event.target.value)}
validate={(value) => validate={(value) =>
value.trim() === "" value.trim() === ""
? "Cannot be empty" ? "Cannot be empty"
: value.trim().length > ISSUE_TITLE_MAX_LENGTH : value.trim().length > ISSUE_TITLE_MAX_LENGTH
? `Too long (${ISSUE_TITLE_MAX_LENGTH} character limit)` ? `Too long (${ISSUE_TITLE_MAX_LENGTH} character limit)`
: undefined : undefined
} }
submitAttempted={submitAttempted} submitAttempted={submitAttempted}
placeholder="Demo Issue" placeholder="Demo Issue"
maxLength={ISSUE_TITLE_MAX_LENGTH} maxLength={ISSUE_TITLE_MAX_LENGTH}
/> />
<Field <Field
label="Description (optional)" label="Description (optional)"
value={description} value={description}
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
validate={(value) => validate={(value) =>
value.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH value.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH
? `Too long (${ISSUE_DESCRIPTION_MAX_LENGTH} character limit)` ? `Too long (${ISSUE_DESCRIPTION_MAX_LENGTH} character limit)`
: undefined : undefined
} }
submitAttempted={submitAttempted} submitAttempted={submitAttempted}
placeholder="Optional details" placeholder="Optional details"
maxLength={ISSUE_DESCRIPTION_MAX_LENGTH} maxLength={ISSUE_DESCRIPTION_MAX_LENGTH}
/> />
{sprints.length > 0 && ( {sprints.length > 0 && (
<div className="flex items-center gap-2 mt-0"> <div className="flex items-center gap-2 mt-0">
<Label className="text-sm">Sprint</Label> <Label className="text-sm">Sprint</Label>
<SprintSelect sprints={sprints} value={sprintId} onChange={setSprintId} /> <SprintSelect sprints={sprints} value={sprintId} onChange={setSprintId} />
</div> </div>
)} )}
{members.length > 0 && ( {members.length > 0 && (
<div className="flex items-start gap-2 mt-4"> <div className="flex items-start gap-2 mt-4">
<Label className="text-sm pt-2">Assignees</Label> <Label className="text-sm pt-2">Assignees</Label>
<MultiAssigneeSelect <MultiAssigneeSelect users={members} assigneeIds={assigneeIds} onChange={setAssigneeIds} />
users={members} </div>
assigneeIds={assigneeIds} )}
onChange={setAssigneeIds}
/>
</div>
)}
<div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2"> <div className="flex items-end justify-end w-full text-xs -mb-2 -mt-2">
{error ? ( {error ? (
<Label className="text-destructive text-sm">{error}</Label> <Label className="text-destructive text-sm">{error}</Label>
) : ( ) : (
<Label className="opacity-0 text-sm">a</Label> <Label className="opacity-0 text-sm">a</Label>
)} )}
</div> </div>
<div className="flex gap-2 w-full justify-end mt-2"> <div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline" type="button"> <Button variant="outline" type="button">
Cancel Cancel
</Button> </Button>
</DialogClose> </DialogClose>
<Button <Button
type="submit" type="submit"
disabled={ disabled={
submitting || submitting ||
((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) && ((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) &&
submitAttempted) || submitAttempted) ||
(description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && (description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && submitAttempted)
submitAttempted) }
} >
> {submitting ? "Creating..." : "Create"}
{submitting ? "Creating..." : "Create"} </Button>
</Button> </div>
</div> </div>
</div> </form>
</form> </DialogContent>
</DialogContent> </Dialog>
</Dialog> );
);
} }

View File

@@ -3,53 +3,53 @@ import { type ReactNode, useMemo } from "react";
import { IssueDetails } from "@/components/issue-details"; import { IssueDetails } from "@/components/issue-details";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { import {
useOrganisationMembers, useOrganisationMembers,
useSelectedOrganisation, useSelectedOrganisation,
useSelectedProject, useSelectedProject,
useSprints, useSprints,
} from "@/lib/query/hooks"; } from "@/lib/query/hooks";
import { issueID } from "@/lib/utils"; import { issueID } from "@/lib/utils";
export function IssueModal({ export function IssueModal({
issueData, issueData,
open, open,
onOpenChange, onOpenChange,
trigger, trigger,
}: { }: {
issueData: IssueResponse | null; issueData: IssueResponse | null;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
trigger: ReactNode; trigger: ReactNode;
}) { }) {
const selectedOrganisation = useSelectedOrganisation(); const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject(); const selectedProject = useSelectedProject();
const { data: sprints = [] } = useSprints(selectedProject?.Project.id); const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
const members = useMemo(() => membersData.map((member) => member.User), [membersData]); const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const statuses = selectedOrganisation?.Organisation.statuses ?? {};
if (!issueData || !selectedProject || !selectedOrganisation) { if (!issueData || !selectedProject || !selectedOrganisation) {
return null; return null;
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{trigger}</DialogTrigger> <DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="w-lg p-0" showCloseButton={false}> <DialogContent className="w-lg p-0" showCloseButton={false}>
<DialogTitle className="sr-only"> <DialogTitle className="sr-only">
{issueID(selectedProject.Project.key, issueData.Issue.number)} {issueID(selectedProject.Project.key, issueData.Issue.number)}
</DialogTitle> </DialogTitle>
<IssueDetails <IssueDetails
issueData={issueData} issueData={issueData}
projectKey={selectedProject.Project.key} projectKey={selectedProject.Project.key}
sprints={sprints} sprints={sprints}
members={members} members={members}
statuses={statuses} statuses={statuses}
onClose={() => onOpenChange(false)} onClose={() => onOpenChange(false)}
onDelete={() => onOpenChange(false)} onDelete={() => onOpenChange(false)}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -7,71 +7,67 @@ import { parseError } from "@/lib/server";
import { cn, formatTime } from "@/lib/utils"; import { cn, formatTime } from "@/lib/utils";
export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data: TimerState) => void }) { export function IssueTimer({ issueId, onEnd }: { issueId: number; onEnd?: (data: TimerState) => void }) {
const { data: timerState, error } = useTimerState(issueId); const { data: timerState, error } = useTimerState(issueId);
const toggleTimer = useToggleTimer(); const toggleTimer = useToggleTimer();
const endTimer = useEndTimer(); const endTimer = useEndTimer();
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!timerState?.isRunning) return; if (!timerState?.isRunning) return;
const interval = setInterval(() => { const interval = setInterval(() => {
setTick((t) => t + 1); setTick((t) => t + 1);
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [timerState?.isRunning]); }, [timerState?.isRunning]);
useEffect(() => { useEffect(() => {
if (!error) return; if (!error) return;
setErrorMessage(parseError(error as Error)); setErrorMessage(parseError(error as Error));
}, [error]); }, [error]);
void tick; void tick;
const displayTime = getWorkTimeMs(timerState?.timestamps); const displayTime = getWorkTimeMs(timerState?.timestamps);
const handleToggle = async () => { const handleToggle = async () => {
try { try {
await toggleTimer.mutateAsync({ issueId }); await toggleTimer.mutateAsync({ issueId });
setErrorMessage(null); setErrorMessage(null);
} catch (err) { } catch (err) {
setErrorMessage(parseError(err as Error)); setErrorMessage(parseError(err as Error));
} }
}; };
const handleEnd = async () => { const handleEnd = async () => {
try { try {
const data = await endTimer.mutateAsync({ issueId }); const data = await endTimer.mutateAsync({ issueId });
if (data) { if (data) {
onEnd?.(data); onEnd?.(data);
} }
setErrorMessage(null); setErrorMessage(null);
} catch (err) { } catch (err) {
setErrorMessage(parseError(err as Error)); setErrorMessage(parseError(err as Error));
} }
}; };
return ( return (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className={cn("text-6xl", !timerState?.isRunning && "text-muted-foreground")}> <div className={cn("text-6xl", !timerState?.isRunning && "text-muted-foreground")}>
{formatTime(displayTime)} {formatTime(displayTime)}
</div> </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"> <div className="flex gap-4">
<Button onClick={handleToggle}> <Button onClick={handleToggle}>
{!timerState ? "Start" : timerState.isRunning ? "Pause" : "Resume"} {!timerState ? "Start" : timerState.isRunning ? "Pause" : "Resume"}
</Button> </Button>
<Button <Button onClick={handleEnd} variant="outline" disabled={!timerState || timerState.endedAt != null}>
onClick={handleEnd} End
variant="outline" </Button>
disabled={!timerState || timerState.endedAt != null} </div>
> </div>
End );
</Button>
</div>
</div>
);
} }

View File

@@ -7,138 +7,135 @@ import { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/qu
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function IssuesTable({ export function IssuesTable({
columns = {}, columns = {},
className, className,
}: { }: {
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean }; columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
className: string; className: string;
}) { }) {
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection(); const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
const { data: issuesData = [] } = useIssues(selectedProjectId); const { data: issuesData = [] } = useIssues(selectedProjectId);
const selectedOrganisation = useSelectedOrganisation(); const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject(); const selectedProject = useSelectedProject();
const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const issues = useMemo(() => [...issuesData].reverse(), [issuesData]); const issues = useMemo(() => [...issuesData].reverse(), [issuesData]);
const getIssueUrl = (issueNumber: number) => { const getIssueUrl = (issueNumber: number) => {
if (!selectedOrganisation || !selectedProject) return "#"; if (!selectedOrganisation || !selectedProject) return "#";
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("o", selectedOrganisation.Organisation.slug.toLowerCase()); params.set("o", selectedOrganisation.Organisation.slug.toLowerCase());
params.set("p", selectedProject.Project.key.toLowerCase()); params.set("p", selectedProject.Project.key.toLowerCase());
params.set("i", issueNumber.toString()); params.set("i", issueNumber.toString());
return `/app?${params.toString()}`; return `/app?${params.toString()}`;
}; };
const handleLinkClick = (e: React.MouseEvent) => { const handleLinkClick = (e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) { if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.stopPropagation(); e.stopPropagation();
return; return;
} }
e.preventDefault(); e.preventDefault();
}; };
return ( return (
<Table className={cn("table-fixed", className)}> <Table className={cn("table-fixed", className)}>
<TableHeader> <TableHeader>
<TableRow hoverEffect={false} className="bg-muted/20"> <TableRow hoverEffect={false} className="bg-muted/20">
{(columns.id == null || columns.id === true) && ( {(columns.id == null || columns.id === true) && (
<TableHead className="text-right w-10 border-r">ID</TableHead> <TableHead className="text-right w-10 border-r">ID</TableHead>
)} )}
{(columns.title == null || columns.title === true) && <TableHead>Title</TableHead>} {(columns.title == null || columns.title === true) && <TableHead>Title</TableHead>}
{(columns.description == null || columns.description === true) && ( {(columns.description == null || columns.description === true) && (
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
)} )}
{/* below is kept blank to fill the space, used as the "Assignee" column */} {/* below is kept blank to fill the space, used as the "Assignee" column */}
{(columns.assignee == null || columns.assignee === true) && ( {(columns.assignee == null || columns.assignee === true) && (
<TableHead className="w-[1%]"></TableHead> <TableHead className="w-[1%]"></TableHead>
)} )}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{issues.map((issueData) => ( {issues.map((issueData) => (
<TableRow <TableRow
key={issueData.Issue.id} key={issueData.Issue.id}
className="cursor-pointer max-w-full" className="cursor-pointer max-w-full"
onClick={() => { onClick={() => {
if (issueData.Issue.id === selectedIssueId) { if (issueData.Issue.id === selectedIssueId) {
selectIssue(null); selectIssue(null);
return; return;
} }
selectIssue(issueData); selectIssue(issueData);
}} }}
> >
{(columns.id == null || columns.id === true) && ( {(columns.id == null || columns.id === true) && (
<TableCell className="font-medium border-r text-right p-0"> <TableCell className="font-medium border-r text-right p-0">
<a <a
href={getIssueUrl(issueData.Issue.number)} href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick} onClick={handleLinkClick}
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent" className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
> >
{issueData.Issue.number.toString().padStart(3, "0")} {issueData.Issue.number.toString().padStart(3, "0")}
</a> </a>
</TableCell> </TableCell>
)} )}
{(columns.title == null || columns.title === true) && ( {(columns.title == null || columns.title === true) && (
<TableCell className="min-w-0 p-0"> <TableCell className="min-w-0 p-0">
<a <a
href={getIssueUrl(issueData.Issue.number)} href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick} 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" 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) && ( {(columns.status == null || columns.status === true) && (
<StatusTag <StatusTag status={issueData.Issue.status} colour={statuses[issueData.Issue.status]} />
status={issueData.Issue.status} )}
colour={statuses[issueData.Issue.status]} <span className="truncate">{issueData.Issue.title}</span>
/> </a>
)} </TableCell>
<span className="truncate">{issueData.Issue.title}</span> )}
</a> {(columns.description == null || columns.description === true) && (
</TableCell> <TableCell className="overflow-hidden p-0">
)} <a
{(columns.description == null || columns.description === true) && ( href={getIssueUrl(issueData.Issue.number)}
<TableCell className="overflow-hidden p-0"> onClick={handleLinkClick}
<a className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
href={getIssueUrl(issueData.Issue.number)} >
onClick={handleLinkClick} {issueData.Issue.description}
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent" </a>
> </TableCell>
{issueData.Issue.description} )}
</a> {(columns.assignee == null || columns.assignee === true) && (
</TableCell> <TableCell className="h-[32px] p-0">
)} <a
{(columns.assignee == null || columns.assignee === true) && ( href={getIssueUrl(issueData.Issue.number)}
<TableCell className="h-[32px] p-0"> onClick={handleLinkClick}
<a className="flex items-center justify-end w-full h-full px-2"
href={getIssueUrl(issueData.Issue.number)} >
onClick={handleLinkClick} {issueData.Assignees && issueData.Assignees.length > 0 && (
className="flex items-center justify-end w-full h-full px-2" <div className="flex items-center -space-x-2 pr-1.5">
> {issueData.Assignees.slice(0, 3).map((assignee) => (
{issueData.Assignees && issueData.Assignees.length > 0 && ( <Avatar
<div className="flex items-center -space-x-2 pr-1.5"> key={assignee.id}
{issueData.Assignees.slice(0, 3).map((assignee) => ( name={assignee.name}
<Avatar username={assignee.username}
key={assignee.id} avatarURL={assignee.avatarURL}
name={assignee.name} textClass="text-xs"
username={assignee.username} className="ring-1 ring-background"
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}
{issueData.Assignees.length > 3 && ( </span>
<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} </div>
</span> )}
)} </a>
</div> </TableCell>
)} )}
</a> </TableRow>
</TableCell> ))}
)} </TableBody>
</TableRow> </Table>
))} );
</TableBody>
</Table>
);
} }

View File

@@ -1,13 +1,11 @@
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
export default function Loading({ message, children }: { message?: string; children?: React.ReactNode }) { export default function Loading({ message, children }: { message?: string; children?: React.ReactNode }) {
return ( return (
<div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]"> <div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]">
<Spinner className="size-6" /> <Spinner className="size-6" />
{message && ( {message && <span className="text-xs px-2 py-1 border-2 border-input border-dashed">{message}</span>}
<span className="text-xs px-2 py-1 border-2 border-input border-dashed">{message}</span> {children}
)} </div>
{children} );
</div>
);
} }

View File

@@ -4,40 +4,40 @@ import Icon from "@/components/ui/icon";
import { clearAuth, cn, getCsrfToken, getServerURL } from "@/lib/utils"; import { clearAuth, cn, getCsrfToken, getServerURL } from "@/lib/utils";
export default function LogOutButton({ export default function LogOutButton({
noStyle = false, noStyle = false,
className, className,
}: { }: {
noStyle?: boolean; noStyle?: boolean;
className?: string; className?: string;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const logOut = async () => { const logOut = async () => {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const headers: HeadersInit = {}; const headers: HeadersInit = {};
if (csrfToken) headers["X-CSRF-Token"] = csrfToken; if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
try { try {
await fetch(`${getServerURL()}/auth/logout`, { await fetch(`${getServerURL()}/auth/logout`, {
method: "POST", method: "POST",
headers, headers,
credentials: "include", credentials: "include",
}); });
} catch {} } catch {}
clearAuth(); clearAuth();
navigate(0); navigate(0);
}; };
return ( return (
<Button <Button
onClick={logOut} onClick={logOut}
variant={noStyle ? "dummy" : "destructive"} variant={noStyle ? "dummy" : "destructive"}
className={cn("flex gap-2 items-center", noStyle && "px-2 py-1 m-0 h-auto", className)} className={cn("flex gap-2 items-center", noStyle && "px-2 py-1 m-0 h-auto", className)}
size={noStyle ? "none" : "default"} size={noStyle ? "none" : "default"}
> >
Log out Log out
<Icon icon="logOut" size={15} /> <Icon icon="logOut" size={15} />
</Button> </Button>
); );
} }

View File

@@ -16,311 +16,305 @@ import { UploadAvatar } from "@/components/upload-avatar";
import { capitalise, cn, getServerURL, setCsrfToken } from "@/lib/utils"; import { capitalise, cn, getServerURL, setCsrfToken } from "@/lib/utils";
const DEMO_USERS = [ const DEMO_USERS = [
{ name: "User 1", username: "u1", password: "a" }, { name: "User 1", username: "u1", password: "a" },
{ name: "User 2", username: "u2", password: "a" }, { name: "User 2", username: "u2", password: "a" },
]; ];
export default function LogInForm() { export default function LogInForm() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { setUser } = useSession(); const { setUser } = useSession();
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false); const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
const [showWarning, setShowWarning] = useState(() => { const [showWarning, setShowWarning] = useState(() => {
return localStorage.getItem("hide-under-construction") !== "true"; 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 [name, setName] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [avatarURL, setAvatarUrl] = useState<string | null>(null); const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const logIn = () => { const logIn = () => {
if (username.trim() === "" || password.trim() === "") { if (username.trim() === "" || password.trim() === "") {
return; 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 });
} }
// unauthorized
fetch(`${getServerURL()}/auth/login`, { else if (res.status === 401) {
method: "POST", setError("Either the username or password is incorrect");
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();
} else { } else {
register(); setError("An unknown error occured.");
} }
}; })
.catch((err) => {
console.error(err);
setError(`${err.statusText}`);
});
};
return ( const register = () => {
<> if (name.trim() === "" || username.trim() === "" || password.trim() === "") {
{/* under construction warning */} return;
{showWarning && ( }
<div className="relative flex flex-col border p-4 items-center border-border/50 bg-border/10 gap-2 max-w-lg">
<IconButton fetch(`${getServerURL()}/auth/register`, {
size="md" method: "POST",
className="absolute top-2 right-2" headers: { "Content-Type": "application/json" },
onClick={() => { body: JSON.stringify({
localStorage.setItem("hide-under-construction", "true"); name,
setShowWarning(false); 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" /> <div className="flex items-center gap-2">
</IconButton> <Avatar name={user.name} username={user.username} />
<Icon icon="alertTriangle" className="w-16 h-16 text-yellow-500" /> <span className="font-semibold">{user.name}</span>
<div className="text-center text-sm text-muted-foreground font-500"> </div>
<div className="text-sm text-muted-foreground space-y-1">
<p> <p>
This application is currently under construction. Your data is very likely to be <span className="font-medium text-foreground">Username:</span> {user.username}
lost at some point.
</p> </p>
<p className="font-700 underline underline-offset-3 text-foreground/85 decoration-yellow-500 mt-2"> <p>
It is not recommended for production use. <span className="font-medium text-foreground">Password:</span> {user.password}
</p> </p>
<p className="mt-2">But you're more than welcome to have a look around!</p> </div>
<Dialog open={loginDetailsOpen} onOpenChange={setLoginDetailsOpen}> </button>
<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> </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}> <ServerConfiguration />
<div <span className="text-xl font-basteleur mb-2">{capitalise(mode)}</span>
className={cn(
"relative flex flex-col gap-2 items-center border p-6 pb-4", <div className={"flex flex-col items-center mb-0"}>
error !== "" && "border-destructive", {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 /> Remove Avatar
<span className="text-xl font-basteleur mb-2">{capitalise(mode)}</span> </Button>
)}
<div className={"flex flex-col items-center mb-0"}> <Field
{mode === "register" && ( label="Full Name"
<> value={name}
<UploadAvatar onChange={(e) => setName(e.target.value)}
name={name} validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
username={username || undefined} submitAttempted={submitAttempted}
avatarURL={avatarURL} spellcheck={false}
onAvatarUploaded={setAvatarUrl} maxLength={USER_NAME_MAX_LENGTH}
className="mb-2" />
/> </>
{avatarURL && ( )}
<Button <Field
variant={"dummy"} label="Username"
type={"button"} value={username}
onClick={() => { onChange={(e) => setUsername(e.target.value)}
setAvatarUrl(null); validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
}} submitAttempted={submitAttempted}
className="-mt-2 mb-2 hover:text-personality" spellcheck={false}
> maxLength={USER_USERNAME_MAX_LENGTH}
Remove Avatar showCounter={mode === "register"}
</Button> />
)} <Field
<Field label="Password"
label="Full Name" value={password}
value={name} onChange={(e) => setPassword(e.target.value)}
onChange={(e) => setName(e.target.value)} validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} hidden={true}
submitAttempted={submitAttempted} submitAttempted={submitAttempted}
spellcheck={false} 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> </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"; import { UserSelect } from "@/components/user-select";
export function MultiAssigneeSelect({ export function MultiAssigneeSelect({
users, users,
assigneeIds, assigneeIds,
onChange, onChange,
fallbackUsers = [], fallbackUsers = [],
}: { }: {
users: UserRecord[]; users: UserRecord[];
assigneeIds: string[]; assigneeIds: string[];
onChange: (assigneeIds: string[]) => void; onChange: (assigneeIds: string[]) => void;
fallbackUsers?: UserRecord[]; fallbackUsers?: UserRecord[];
}) { }) {
const handleAssigneeChange = (index: number, value: string) => { const handleAssigneeChange = (index: number, value: string) => {
// if set to "unassigned" and there are other rows, remove this row // if set to "unassigned" and there are other rows, remove this row
if (value === "unassigned" && assigneeIds.length > 1) { if (value === "unassigned" && assigneeIds.length > 1) {
const newAssigneeIds = assigneeIds.filter((_, i) => i !== index); const newAssigneeIds = assigneeIds.filter((_, i) => i !== index);
onChange(newAssigneeIds); onChange(newAssigneeIds);
return; return;
} }
const newAssigneeIds = [...assigneeIds]; const newAssigneeIds = [...assigneeIds];
newAssigneeIds[index] = value; newAssigneeIds[index] = value;
onChange(newAssigneeIds); onChange(newAssigneeIds);
}; };
const handleAddAssignee = () => { const handleAddAssignee = () => {
onChange([...assigneeIds, "unassigned"]); onChange([...assigneeIds, "unassigned"]);
}; };
const getAvailableUsers = (currentIndex: number) => { const getAvailableUsers = (currentIndex: number) => {
const selectedIds = assigneeIds const selectedIds = assigneeIds
.filter((_, i) => i !== currentIndex) .filter((_, i) => i !== currentIndex)
.filter((id) => id !== "unassigned") .filter((id) => id !== "unassigned")
.map((id) => Number(id)); .map((id) => Number(id));
return users.filter((user) => !selectedIds.includes(user.id)); return users.filter((user) => !selectedIds.includes(user.id));
}; };
const getFallbackUser = (assigneeId: string) => { const getFallbackUser = (assigneeId: string) => {
if (assigneeId === "unassigned") return null; if (assigneeId === "unassigned") return null;
return fallbackUsers.find((u) => u.id.toString() === assigneeId) || null; return fallbackUsers.find((u) => u.id.toString() === assigneeId) || null;
}; };
const selectedCount = assigneeIds.filter((id) => id !== "unassigned").length; const selectedCount = assigneeIds.filter((id) => id !== "unassigned").length;
const lastRowHasSelection = assigneeIds[assigneeIds.length - 1] !== "unassigned"; const lastRowHasSelection = assigneeIds[assigneeIds.length - 1] !== "unassigned";
const canAddMore = selectedCount < users.length && lastRowHasSelection; const canAddMore = selectedCount < users.length && lastRowHasSelection;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{assigneeIds.map((assigneeId, index) => ( {assigneeIds.map((assigneeId, index) => (
<> <>
<div key={`assignee-${index}-${assigneeId}`} className="flex items-center gap-1"> <div key={`assignee-${index}-${assigneeId}`} className="flex items-center gap-1">
<UserSelect <UserSelect
users={getAvailableUsers(index)} users={getAvailableUsers(index)}
value={assigneeId} value={assigneeId}
onChange={(value) => handleAssigneeChange(index, value)} onChange={(value) => handleAssigneeChange(index, value)}
fallbackUser={getFallbackUser(assigneeId)} fallbackUser={getFallbackUser(assigneeId)}
/> />
</div> </div>
{index === assigneeIds.length - 1 && canAddMore && ( {index === assigneeIds.length - 1 && canAddMore && (
<IconButton onClick={handleAddAssignee} title={"Add assignee"} className="w-9 h-9"> <IconButton onClick={handleAddAssignee} title={"Add assignee"} className="w-9 h-9">
<Icon icon="plus" className="h-4 w-4" /> <Icon icon="plus" className="h-4 w-4" />
</IconButton> </IconButton>
)} )}
</> </>
))} ))}
</div> </div>
); );
} }

View File

@@ -1,81 +1,77 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const FALLBACK_COLOURS = [ const FALLBACK_COLOURS = [
"bg-teal-500", "bg-teal-500",
"bg-rose-500", "bg-rose-500",
"bg-indigo-500", "bg-indigo-500",
"bg-amber-500", "bg-amber-500",
"bg-cyan-500", "bg-cyan-500",
"bg-purple-500", "bg-purple-500",
"bg-lime-500", "bg-lime-500",
"bg-orange-500", "bg-orange-500",
"bg-sky-500", "bg-sky-500",
"bg-fuchsia-500", "bg-fuchsia-500",
"bg-green-500", "bg-green-500",
"bg-red-500", "bg-red-500",
"bg-violet-500", "bg-violet-500",
"bg-yellow-500", "bg-yellow-500",
"bg-blue-500", "bg-blue-500",
"bg-emerald-500", "bg-emerald-500",
"bg-pink-500", "bg-pink-500",
]; ];
function hashStringToIndex(value: string, modulo: number) { function hashStringToIndex(value: string, modulo: number) {
let hash = 0; let hash = 0;
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0; hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
} }
return modulo === 0 ? 0 : hash % modulo; return modulo === 0 ? 0 : hash % modulo;
} }
function getInitials(name: string) { function getInitials(name: string) {
name = name.trim(); name = name.trim();
const parts = name.split(/[^a-zA-Z0-9]+/).filter(Boolean); const parts = name.split(/[^a-zA-Z0-9]+/).filter(Boolean);
if (parts.length === 0) return name.slice(0, 2).toUpperCase(); 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({ export default function OrgIcon({
name, name,
slug, slug,
iconURL, iconURL,
size, size,
textClass = "text-md", textClass = "text-md",
className, className,
}: { }: {
name: string; name: string;
slug: string; slug: string;
iconURL?: string | null; iconURL?: string | null;
size?: number; size?: number;
textClass?: string; textClass?: string;
className?: string; className?: string;
}) { }) {
const backgroundClass = FALLBACK_COLOURS[hashStringToIndex(slug, FALLBACK_COLOURS.length)]; const backgroundClass = FALLBACK_COLOURS[hashStringToIndex(slug, FALLBACK_COLOURS.length)];
return ( return (
<div <div
className={cn( className={cn(
"flex items-center justify-center rounded-sm overflow-hidden", "flex items-center justify-center rounded-sm overflow-hidden",
"text-white font-medium select-none", "text-white font-medium select-none",
!iconURL && backgroundClass, !iconURL && backgroundClass,
`w-${size || 6}`, `w-${size || 6}`,
`h-${size || 6}`, `h-${size || 6}`,
className, className,
)} )}
> >
{iconURL ? ( {iconURL ? (
<img <img src={iconURL} alt={name} className={`rounded-md object-cover w-${size || 6} h-${size || 6}`} />
src={iconURL} ) : (
alt={name} <span className={textClass}>{getInitials(name)}</span>
className={`rounded-md object-cover w-${size || 6} h-${size || 6}`} )}
/> </div>
) : ( );
<span className={textClass}>{getInitials(name)}</span>
)}
</div>
);
} }

View File

@@ -1,20 +1,20 @@
import { import {
ORG_DESCRIPTION_MAX_LENGTH, ORG_DESCRIPTION_MAX_LENGTH,
ORG_NAME_MAX_LENGTH, ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH, ORG_SLUG_MAX_LENGTH,
type OrganisationRecord, type OrganisationRecord,
} from "@sprint/shared"; } from "@sprint/shared";
import { type FormEvent, useEffect, useState } from "react"; import { type FormEvent, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -24,258 +24,258 @@ import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const slugify = (value: string) => const slugify = (value: string) =>
value value
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "") .replace(/^-+/, "")
.replace(/-{2,}/g, "-"); .replace(/-{2,}/g, "-");
export function OrganisationForm({ export function OrganisationForm({
trigger, trigger,
completeAction, completeAction,
errorAction, errorAction,
mode = "create", mode = "create",
existingOrganisation, existingOrganisation,
open: controlledOpen, open: controlledOpen,
onOpenChange: controlledOnOpenChange, onOpenChange: controlledOnOpenChange,
}: { }: {
trigger?: React.ReactNode; trigger?: React.ReactNode;
completeAction?: (org: OrganisationRecord) => void | Promise<void>; completeAction?: (org: OrganisationRecord) => void | Promise<void>;
errorAction?: (errorMessage: string) => void | Promise<void>; errorAction?: (errorMessage: string) => void | Promise<void>;
mode?: "create" | "edit"; mode?: "create" | "edit";
existingOrganisation?: OrganisationRecord; existingOrganisation?: OrganisationRecord;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
}) { }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const createOrganisation = useCreateOrganisation(); const createOrganisation = useCreateOrganisation();
const updateOrganisation = useUpdateOrganisation(); const updateOrganisation = useUpdateOrganisation();
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? controlledOpen : internalOpen; const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen; const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
const [name, setName] = useState(""); const [name, setName] = useState("");
const [slug, setSlug] = useState(""); const [slug, setSlug] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [iconURL, setIconURL] = useState<string | null>(null); const [iconURL, setIconURL] = useState<string | null>(null);
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isEdit = mode === "edit"; const isEdit = mode === "edit";
useEffect(() => { useEffect(() => {
if (isEdit && existingOrganisation && open) { if (isEdit && existingOrganisation && open) {
setName(existingOrganisation.name); setName(existingOrganisation.name);
setSlug(existingOrganisation.slug); setSlug(existingOrganisation.slug);
setDescription(existingOrganisation.description ?? ""); setDescription(existingOrganisation.description ?? "");
setIconURL(existingOrganisation.iconURL ?? null); setIconURL(existingOrganisation.iconURL ?? null);
setSlugManuallyEdited(true); setSlugManuallyEdited(true);
} }
}, [isEdit, existingOrganisation, open]); }, [isEdit, existingOrganisation, open]);
const reset = () => { const reset = () => {
setName(""); setName("");
setSlug(""); setSlug("");
setDescription(""); setDescription("");
setIconURL(null); setIconURL(null);
setSlugManuallyEdited(false); setSlugManuallyEdited(false);
setSubmitAttempted(false); setSubmitAttempted(false);
setSubmitting(false); setSubmitting(false);
setError(null); setError(null);
}; };
const onOpenChange = (nextOpen: boolean) => { const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen); setOpen(nextOpen);
if (!nextOpen) { if (!nextOpen) {
reset(); reset();
} }
}; };
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setSubmitAttempted(true); setSubmitAttempted(true);
if (name.trim() === "" || name.trim().length > ORG_NAME_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 (slug.trim() === "" || slug.trim().length > ORG_SLUG_MAX_LENGTH) return;
if (description.trim().length > ORG_DESCRIPTION_MAX_LENGTH) return; if (description.trim().length > ORG_DESCRIPTION_MAX_LENGTH) return;
if (!user.id) { if (!user.id) {
setError(`you must be logged in to ${isEdit ? "edit" : "create"} an organisation`); setError(`you must be logged in to ${isEdit ? "edit" : "create"} an organisation`);
return; 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>
);
} }
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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild> {dialogContent}
{trigger || <Button variant="outline">Create Organisation</Button>} </Dialog>
</DialogTrigger>
{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 { useSelection } from "@/components/selection-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useOrganisations } from "@/lib/query/hooks"; import { useOrganisations } from "@/lib/query/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import OrgIcon from "./org-icon"; import OrgIcon from "./org-icon";
export function OrganisationSelect({ export function OrganisationSelect({
placeholder = "Select Organisation", placeholder = "Select Organisation",
contentClass, contentClass,
showLabel = false, showLabel = false,
label = "Organisation", label = "Organisation",
labelPosition = "top", labelPosition = "top",
triggerClassName, triggerClassName,
noDecoration, noDecoration,
trigger, trigger,
}: { }: {
placeholder?: string; placeholder?: string;
contentClass?: string; contentClass?: string;
showLabel?: boolean; showLabel?: boolean;
label?: string; label?: string;
labelPosition?: "top" | "bottom"; labelPosition?: "top" | "bottom";
triggerClassName?: string; triggerClassName?: string;
noDecoration?: boolean; noDecoration?: boolean;
trigger?: React.ReactNode; trigger?: React.ReactNode;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null); const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
const { data: organisationsData = [] } = useOrganisations(); const { data: organisationsData = [] } = useOrganisations();
const { selectedOrganisationId, selectOrganisation } = useSelection(); const { selectedOrganisationId, selectOrganisation } = useSelection();
const organisations = useMemo( const organisations = useMemo(
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
[organisationsData], [organisationsData],
); );
const selectedOrganisation = useMemo( const selectedOrganisation = useMemo(
() => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null, () => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
[organisations, selectedOrganisationId], [organisations, selectedOrganisationId],
); );
useEffect(() => { useEffect(() => {
if (!pendingOrganisationId) return; if (!pendingOrganisationId) return;
const organisation = organisations.find((org) => org.Organisation.id === pendingOrganisationId); const organisation = organisations.find((org) => org.Organisation.id === pendingOrganisationId);
if (organisation) { if (organisation) {
selectOrganisation(organisation); selectOrganisation(organisation);
setPendingOrganisationId(null); 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 ( {organisations.length > 0 && <SelectSeparator />}
<Select </SelectGroup>
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 />} <OrganisationForm
</SelectGroup> trigger={
<Button variant="ghost" className={"w-full"} size={"sm"}>
<OrganisationForm Create Organisation
trigger={ </Button>
<Button variant="ghost" className={"w-full"} size={"sm"}> }
Create Organisation completeAction={async (org) => {
</Button> try {
} setPendingOrganisationId(org.id);
completeAction={async (org) => { } catch (err) {
try { console.error(err);
setPendingOrganisationId(org.id); }
} catch (err) { }}
console.error(err); errorAction={async (errorMessage) => {
} toast.error(`Error creating organisation: ${errorMessage}`, {
}} dismissible: false,
errorAction={async (errorMessage) => { });
toast.error(`Error creating organisation: ${errorMessage}`, { }}
dismissible: false, />
}); </SelectContent>
}} </Select>
/> );
</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 { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -18,236 +18,236 @@ import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const keyify = (value: string) => const keyify = (value: string) =>
value value
.toUpperCase() .toUpperCase()
.replace(/[^A-Z0-9]/g, "") .replace(/[^A-Z0-9]/g, "")
.slice(0, 4); .slice(0, 4);
export function ProjectForm({ export function ProjectForm({
organisationId, organisationId,
trigger, trigger,
completeAction, completeAction,
mode = "create", mode = "create",
existingProject, existingProject,
open: controlledOpen, open: controlledOpen,
onOpenChange: controlledOnOpenChange, onOpenChange: controlledOnOpenChange,
}: { }: {
organisationId?: number; organisationId?: number;
trigger?: React.ReactNode; trigger?: React.ReactNode;
completeAction?: (project: ProjectRecord) => void | Promise<void>; completeAction?: (project: ProjectRecord) => void | Promise<void>;
mode?: "create" | "edit"; mode?: "create" | "edit";
existingProject?: ProjectRecord; existingProject?: ProjectRecord;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
}) { }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const createProject = useCreateProject(); const createProject = useCreateProject();
const updateProject = useUpdateProject(); const updateProject = useUpdateProject();
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? controlledOpen : internalOpen; const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen; const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
const [name, setName] = useState(""); const [name, setName] = useState("");
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [keyManuallyEdited, setKeyManuallyEdited] = useState(false); const [keyManuallyEdited, setKeyManuallyEdited] = useState(false);
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isEdit = mode === "edit"; const isEdit = mode === "edit";
useEffect(() => { useEffect(() => {
if (isEdit && existingProject && open) { if (isEdit && existingProject && open) {
setName(existingProject.name); setName(existingProject.name);
setKey(existingProject.key); setKey(existingProject.key);
setKeyManuallyEdited(true); setKeyManuallyEdited(true);
} }
}, [isEdit, existingProject, open]); }, [isEdit, existingProject, open]);
const reset = () => { const reset = () => {
setName(""); setName("");
setKey(""); setKey("");
setKeyManuallyEdited(false); setKeyManuallyEdited(false);
setSubmitAttempted(false); setSubmitAttempted(false);
setSubmitting(false); setSubmitting(false);
setError(null); setError(null);
}; };
const onOpenChange = (nextOpen: boolean) => { const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen); setOpen(nextOpen);
if (!nextOpen) { if (!nextOpen) {
reset(); reset();
} }
}; };
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setSubmitAttempted(true); setSubmitAttempted(true);
if ( if (
name.trim() === "" || name.trim() === "" ||
name.trim().length > PROJECT_NAME_MAX_LENGTH || name.trim().length > PROJECT_NAME_MAX_LENGTH ||
key.trim() === "" || key.trim() === "" ||
key.length > 4 key.length > 4
) { ) {
return; 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 (!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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild> {dialogContent}
{trigger || ( </Dialog>
<Button variant="outline" disabled={!organisationId}>
Create Project
</Button>
)}
</DialogTrigger>
{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 { useSelection } from "@/components/selection-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useProjects } from "@/lib/query/hooks"; import { useProjects } from "@/lib/query/hooks";
export function ProjectSelect({ export function ProjectSelect({
placeholder = "Select Project", placeholder = "Select Project",
showLabel = false, showLabel = false,
label = "Project", label = "Project",
labelPosition = "top", labelPosition = "top",
}: { }: {
placeholder?: string; placeholder?: string;
showLabel?: boolean; showLabel?: boolean;
label?: string; label?: string;
labelPosition?: "top" | "bottom"; labelPosition?: "top" | "bottom";
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null); const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection(); const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
const { data: projectsData = [] } = useProjects(selectedOrganisationId); const { data: projectsData = [] } = useProjects(selectedOrganisationId);
const projects = useMemo( const projects = useMemo(
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)), () => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
[projectsData], [projectsData],
); );
const selectedProject = useMemo( const selectedProject = useMemo(
() => projects.find((proj) => proj.Project.id === selectedProjectId) ?? null, () => projects.find((proj) => proj.Project.id === selectedProjectId) ?? null,
[projects, selectedProjectId], [projects, selectedProjectId],
); );
useEffect(() => { useEffect(() => {
if (!pendingProjectId) return; if (!pendingProjectId) return;
const project = projects.find((proj) => proj.Project.id === pendingProjectId); const project = projects.find((proj) => proj.Project.id === pendingProjectId);
if (project) { if (project) {
selectProject(project); selectProject(project);
setPendingProjectId(null); 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]); selectProject(project);
}}
return ( onOpenChange={setOpen}
<Select >
value={selectedProject ? `${selectedProject.Project.id}` : undefined} <SelectTrigger
onValueChange={(value) => { className="text-sm"
const project = projects.find((p) => p.Project.id === Number(value)); isOpen={open}
if (!project) { label={showLabel ? label : undefined}
console.error(`NO PROJECT FOUND FOR ID: ${value}`); hasValue={!!selectedProject}
return; labelPosition={labelPosition}
} >
selectProject(project); <SelectValue placeholder={placeholder} />
}} </SelectTrigger>
onOpenChange={setOpen} <SelectContent side="bottom" position="popper" align={"start"}>
> <SelectGroup>
<SelectTrigger <SelectLabel>Projects</SelectLabel>
className="text-sm" {projects.map((project) => (
isOpen={open} <SelectItem key={project.Project.id} value={`${project.Project.id}`}>
label={showLabel ? label : undefined} {project.Project.name}
hasValue={!!selectedProject} </SelectItem>
labelPosition={labelPosition} ))}
> {projects.length > 0 && <SelectSeparator />}
<SelectValue placeholder={placeholder} /> </SelectGroup>
</SelectTrigger> <ProjectForm
<SelectContent side="bottom" position="popper" align={"start"}> organisationId={selectedOrganisationId ?? undefined}
<SelectGroup> trigger={
<SelectLabel>Projects</SelectLabel> <Button size={"sm"} variant="ghost" className={"w-full"} disabled={!selectedOrganisationId}>
{projects.map((project) => ( Create Project
<SelectItem key={project.Project.id} value={`${project.Project.id}`}> </Button>
{project.Project.name} }
</SelectItem> completeAction={async (project) => {
))} try {
{projects.length > 0 && <SelectSeparator />} setPendingProjectId(project.id);
</SelectGroup> } catch (err) {
<ProjectForm console.error(err);
organisationId={selectedOrganisationId ?? undefined} }
trigger={ }}
<Button />
size={"sm"} </SelectContent>
variant="ghost" </Select>
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"; import { queryClient } from "@/lib/query/client";
export function QueryProvider({ children }: { children: ReactNode }) { export function QueryProvider({ children }: { children: ReactNode }) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@@ -3,152 +3,152 @@ import type { ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react"; import { createContext, useCallback, useContext, useMemo, useState } from "react";
type SelectionContextValue = { type SelectionContextValue = {
selectedOrganisationId: number | null; selectedOrganisationId: number | null;
selectedProjectId: number | null; selectedProjectId: number | null;
selectedIssueId: number | null; selectedIssueId: number | null;
initialParams: { initialParams: {
orgSlug: string; orgSlug: string;
projectKey: string; projectKey: string;
issueNumber: number | null; issueNumber: number | null;
}; };
selectOrganisation: (organisation: OrganisationResponse | null, options?: SelectionOptions) => void; selectOrganisation: (organisation: OrganisationResponse | null, options?: SelectionOptions) => void;
selectProject: (project: ProjectResponse | null, options?: SelectionOptions) => void; selectProject: (project: ProjectResponse | null, options?: SelectionOptions) => void;
selectIssue: (issue: IssueResponse | null, options?: SelectionOptions) => void; selectIssue: (issue: IssueResponse | null, options?: SelectionOptions) => void;
}; };
type SelectionOptions = { type SelectionOptions = {
skipUrlUpdate?: boolean; skipUrlUpdate?: boolean;
}; };
const SelectionContext = createContext<SelectionContextValue | null>(null); const SelectionContext = createContext<SelectionContextValue | null>(null);
const readStoredId = (key: string) => { const readStoredId = (key: string) => {
const value = localStorage.getItem(key); const value = localStorage.getItem(key);
if (!value) return null; if (!value) return null;
const parsed = Number(value); const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
}; };
const updateUrlParams = (updates: { const updateUrlParams = (updates: {
orgSlug?: string | null; orgSlug?: string | null;
projectKey?: string | null; projectKey?: string | null;
issueNumber?: number | 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 !== undefined) {
if (updates.orgSlug) params.set("o", updates.orgSlug); if (updates.orgSlug) params.set("o", updates.orgSlug);
else params.delete("o"); else params.delete("o");
} }
if (updates.projectKey !== undefined) { if (updates.projectKey !== undefined) {
if (updates.projectKey) params.set("p", updates.projectKey); if (updates.projectKey) params.set("p", updates.projectKey);
else params.delete("p"); else params.delete("p");
} }
if (updates.issueNumber !== undefined) { if (updates.issueNumber !== undefined) {
if (updates.issueNumber != null) params.set("i", `${updates.issueNumber}`); if (updates.issueNumber != null) params.set("i", `${updates.issueNumber}`);
else params.delete("i"); else params.delete("i");
} }
const search = params.toString(); const search = params.toString();
const nextUrl = `${window.location.pathname}${search ? `?${search}` : ""}`; const nextUrl = `${window.location.pathname}${search ? `?${search}` : ""}`;
window.history.replaceState(null, "", nextUrl); window.history.replaceState(null, "", nextUrl);
}; };
export function SelectionProvider({ children }: { children: ReactNode }) { export function SelectionProvider({ children }: { children: ReactNode }) {
const initialParams = useMemo(() => { const initialParams = useMemo(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const orgSlug = params.get("o")?.trim().toLowerCase() ?? ""; const orgSlug = params.get("o")?.trim().toLowerCase() ?? "";
const projectKey = params.get("p")?.trim().toLowerCase() ?? ""; const projectKey = params.get("p")?.trim().toLowerCase() ?? "";
const issueParam = params.get("i")?.trim() ?? ""; const issueParam = params.get("i")?.trim() ?? "";
const issueNumber = issueParam === "" ? null : Number.parseInt(issueParam, 10); const issueNumber = issueParam === "" ? null : Number.parseInt(issueParam, 10);
return { return {
orgSlug, orgSlug,
projectKey, projectKey,
issueNumber: issueNumber != null && Number.isNaN(issueNumber) ? null : issueNumber, issueNumber: issueNumber != null && Number.isNaN(issueNumber) ? null : issueNumber,
}; };
}, []); }, []);
const [selectedOrganisationId, setSelectedOrganisationId] = useState<number | null>(() => const [selectedOrganisationId, setSelectedOrganisationId] = useState<number | null>(() =>
readStoredId("selectedOrganisationId"), readStoredId("selectedOrganisationId"),
); );
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(() => const [selectedProjectId, setSelectedProjectId] = useState<number | null>(() =>
readStoredId("selectedProjectId"), readStoredId("selectedProjectId"),
); );
const [selectedIssueId, setSelectedIssueId] = useState<number | null>(null); const [selectedIssueId, setSelectedIssueId] = useState<number | null>(null);
const selectOrganisation = useCallback( const selectOrganisation = useCallback(
(organisation: OrganisationResponse | null, options?: SelectionOptions) => { (organisation: OrganisationResponse | null, options?: SelectionOptions) => {
const id = organisation?.Organisation.id ?? null; const id = organisation?.Organisation.id ?? null;
setSelectedOrganisationId(id); setSelectedOrganisationId(id);
setSelectedProjectId(null); setSelectedProjectId(null);
setSelectedIssueId(null); setSelectedIssueId(null);
if (id != null) localStorage.setItem("selectedOrganisationId", `${id}`); if (id != null) localStorage.setItem("selectedOrganisationId", `${id}`);
else localStorage.removeItem("selectedOrganisationId"); else localStorage.removeItem("selectedOrganisationId");
localStorage.removeItem("selectedProjectId"); localStorage.removeItem("selectedProjectId");
if (!options?.skipUrlUpdate) { if (!options?.skipUrlUpdate) {
updateUrlParams({ updateUrlParams({
orgSlug: organisation?.Organisation.slug.toLowerCase() ?? null, orgSlug: organisation?.Organisation.slug.toLowerCase() ?? null,
projectKey: null, projectKey: null,
issueNumber: null, issueNumber: null,
}); });
} }
}, },
[], [],
); );
const selectProject = useCallback((project: ProjectResponse | null, options?: SelectionOptions) => { const selectProject = useCallback((project: ProjectResponse | null, options?: SelectionOptions) => {
const id = project?.Project.id ?? null; const id = project?.Project.id ?? null;
setSelectedProjectId(id); setSelectedProjectId(id);
setSelectedIssueId(null); setSelectedIssueId(null);
if (id != null) localStorage.setItem("selectedProjectId", `${id}`); if (id != null) localStorage.setItem("selectedProjectId", `${id}`);
else localStorage.removeItem("selectedProjectId"); else localStorage.removeItem("selectedProjectId");
if (!options?.skipUrlUpdate) { if (!options?.skipUrlUpdate) {
updateUrlParams({ updateUrlParams({
projectKey: project?.Project.key.toLowerCase() ?? null, projectKey: project?.Project.key.toLowerCase() ?? null,
issueNumber: null, issueNumber: null,
}); });
} }
}, []); }, []);
const selectIssue = useCallback((issue: IssueResponse | null, options?: SelectionOptions) => { const selectIssue = useCallback((issue: IssueResponse | null, options?: SelectionOptions) => {
const id = issue?.Issue.id ?? null; const id = issue?.Issue.id ?? null;
setSelectedIssueId(id); setSelectedIssueId(id);
if (!options?.skipUrlUpdate) { if (!options?.skipUrlUpdate) {
updateUrlParams({ issueNumber: issue?.Issue.number ?? null }); updateUrlParams({ issueNumber: issue?.Issue.number ?? null });
} }
}, []); }, []);
const value = useMemo<SelectionContextValue>( const value = useMemo<SelectionContextValue>(
() => ({ () => ({
selectedOrganisationId, selectedOrganisationId,
selectedProjectId, selectedProjectId,
selectedIssueId, selectedIssueId,
initialParams, initialParams,
selectOrganisation, selectOrganisation,
selectProject, selectProject,
selectIssue, selectIssue,
}), }),
[ [
selectedOrganisationId, selectedOrganisationId,
selectedProjectId, selectedProjectId,
selectedIssueId, selectedIssueId,
initialParams, initialParams,
selectOrganisation, selectOrganisation,
selectProject, selectProject,
selectIssue, selectIssue,
], ],
); );
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>; return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
} }
export function useSelection() { export function useSelection() {
const context = useContext(SelectionContext); const context = useContext(SelectionContext);
if (!context) { if (!context) {
throw new Error("useSelection must be used within SelectionProvider"); throw new Error("useSelection must be used within SelectionProvider");
} }
return context; return context;
} }

View File

@@ -10,176 +10,170 @@ import { getServerURL } from "@/lib/utils";
const DEFAULT_URL = "https://tnirps.ob248.com"; const DEFAULT_URL = "https://tnirps.ob248.com";
const formatURL = (url: string) => { const formatURL = (url: string) => {
if (url.endsWith("/")) { if (url.endsWith("/")) {
url = url.slice(0, -1); url = url.slice(0, -1);
} }
if ( if (
(url.includes("localhost") || url.includes("127.0.0.1")) && (url.includes("localhost") || url.includes("127.0.0.1")) &&
!url.startsWith("http://") && !url.startsWith("http://") &&
!url.startsWith("https://") !url.startsWith("https://")
) { ) {
url = `http://${url}`; // use http for localhost url = `http://${url}`; // use http for localhost
} else if (!url.startsWith("http://") && !url.startsWith("https://")) { } else if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = `https://${url}`; // assume https if none is provided url = `https://${url}`; // assume https if none is provided
} }
return url; return url;
}; };
const isValidURL = (url: string) => { const isValidURL = (url: string) => {
try { try {
new URL(formatURL(url)); new URL(formatURL(url));
return true; return true;
} catch { } catch {
return false; return false;
} }
}; };
export function ServerConfiguration({ trigger }: { trigger?: ReactNode }) { export function ServerConfiguration({ trigger }: { trigger?: ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [serverURL, setServerURL] = useState(getServerURL()); const [serverURL, setServerURL] = useState(getServerURL());
const [originalURL, setOriginalURL] = useState(getServerURL()); const [originalURL, setOriginalURL] = useState(getServerURL());
const [countdown, setCountdown] = useState<number | null>(null); const [countdown, setCountdown] = useState<number | null>(null);
const [isValid, setIsValid] = useState(true); const [isValid, setIsValid] = useState(true);
const [isCheckingHealth, setIsCheckingHealth] = useState(false); const [isCheckingHealth, setIsCheckingHealth] = useState(false);
const [healthError, setHealthError] = useState<string | null>(null); const [healthError, setHealthError] = useState<string | null>(null);
const hasChanged = formatURL(serverURL) !== formatURL(originalURL); const hasChanged = formatURL(serverURL) !== formatURL(originalURL);
const isNotDefault = formatURL(serverURL) !== formatURL(DEFAULT_URL); const isNotDefault = formatURL(serverURL) !== formatURL(DEFAULT_URL);
const canSave = hasChanged && isValidURL(serverURL); const canSave = hasChanged && isValidURL(serverURL);
const handleOpenChange = (nextOpen: boolean) => { const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen); setOpen(nextOpen);
if (nextOpen) { if (nextOpen) {
setServerURL(getServerURL()); setServerURL(getServerURL());
setOriginalURL(getServerURL()); setOriginalURL(getServerURL());
setIsValid(true); setIsValid(true);
setCountdown(null); setCountdown(null);
setHealthError(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) => { return (
setServerURL(value); <>
setIsValid(isValidURL(value)); <Dialog open={open} onOpenChange={handleOpenChange}>
setHealthError(null); <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 = () => { <DialogContent>
setServerURL(DEFAULT_URL); <DialogHeader>
setIsValid(true); <DialogTitle>Server Configuration</DialogTitle>
setHealthError(null); </DialogHeader>
};
const handleSave = async () => { <div className="grid gap-4">
if (!canSave) return; <div className="grid gap-2">
<Label htmlFor="server-url">Server URL</Label>
setIsCheckingHealth(true); <div className="flex gap-2">
<Input
try { id="server-url"
const response = await fetch(`${formatURL(serverURL)}/health`, { value={serverURL}
method: "GET", onChange={(e) => handleServerURLChange(e.target.value)}
signal: AbortSignal.timeout(5000), onKeyDown={(e) => {
}); if (e.key === "Enter" && canSave && !isCheckingHealth) {
e.preventDefault();
if (!response.ok) { void handleSave();
throw new Error(`Server returned ${response.status}`); }
} }}
setHealthError(null); placeholder="https://example.com"
className={!isValid ? "border-destructive" : ""}
localStorage.setItem("serverURL", formatURL(serverURL)); spellCheck={false}
/>
let count = 3; <IconButton
setCountdown(count); variant={canSave ? "primary" : "outline"}
setOpen(false); size="md"
disabled={!canSave || isCheckingHealth}
const interval = setInterval(() => { onClick={handleSave}
count--; >
if (count <= 0) { <Icon icon="checkIcon" className="size-4" />
clearInterval(interval); </IconButton>
window.location.reload(); <IconButton
} else { variant="secondary"
setCountdown(count); size="md"
} disabled={!isNotDefault || isCheckingHealth}
}, 1000); onClick={handleResetToDefault}
} catch (err) { title="Reset to default"
setHealthError(err instanceof Error ? err.message : "Failed to connect to server"); >
} finally { <Icon icon="undo2" className="size-4" />
setIsCheckingHealth(false); </IconButton>
} </div>
}; {!isValid && <Label className="text-destructive text-sm">Please enter a valid URL</Label>}
{healthError && <Label className="text-destructive text-sm">{healthError}</Label>}
return ( </div>
<> </div>
<Dialog open={open} onOpenChange={handleOpenChange}> </DialogContent>
<DialogTrigger asChild> </Dialog>
{trigger || ( {countdown !== null &&
<IconButton createPortal(
size="lg" <div className="fixed inset-0 z-[100] bg-black/50 flex flex-col items-center justify-center pointer-events-auto">
className="absolute top-2 right-2" <div className="text-2xl font-bold pointer-events-none noselect">Redirecting</div>
title={"Server Configuration"} <div className="text-8xl font-bold pointer-events-none noselect">{countdown}</div>
> </div>,
<Icon icon="serverIcon" className="size-4" /> document.body,
</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,
)}
</>
);
} }

View File

@@ -5,85 +5,85 @@ import Loading from "@/components/loading";
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils"; import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
interface SessionContextValue { interface SessionContextValue {
user: UserRecord | null; user: UserRecord | null;
setUser: (user: UserRecord) => void; setUser: (user: UserRecord) => void;
isLoading: boolean; isLoading: boolean;
} }
const SessionContext = createContext<SessionContextValue | null>(null); const SessionContext = createContext<SessionContextValue | null>(null);
// for use outside RequireAuth // for use outside RequireAuth
export function useSession(): SessionContextValue { export function useSession(): SessionContextValue {
const context = useContext(SessionContext); const context = useContext(SessionContext);
if (!context) { if (!context) {
throw new Error("useSession must be used within a SessionProvider"); throw new Error("useSession must be used within a SessionProvider");
} }
return context; return context;
} }
// safe version that returns null if outside provider // safe version that returns null if outside provider
export function useSessionSafe(): SessionContextValue | null { export function useSessionSafe(): SessionContextValue | null {
return useContext(SessionContext); return useContext(SessionContext);
} }
// for use inside RequireAuth // for use inside RequireAuth
export function useAuthenticatedSession(): { user: UserRecord; setUser: (user: UserRecord) => void } { export function useAuthenticatedSession(): { user: UserRecord; setUser: (user: UserRecord) => void } {
const { user, setUser } = useSession(); const { user, setUser } = useSession();
if (!user) { if (!user) {
throw new Error("useAuthenticatedSession must be used within RequireAuth"); throw new Error("useAuthenticatedSession must be used within RequireAuth");
} }
return { user, setUser }; return { user, setUser };
} }
export function SessionProvider({ children }: { children: React.ReactNode }) { export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUserState] = useState<UserRecord | null>(null); const [user, setUserState] = useState<UserRecord | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const fetched = useRef(false); const fetched = useRef(false);
const setUser = useCallback((user: UserRecord) => { const setUser = useCallback((user: UserRecord) => {
setUserState(user); setUserState(user);
localStorage.setItem("user", JSON.stringify(user)); localStorage.setItem("user", JSON.stringify(user));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (fetched.current) return; if (fetched.current) return;
fetched.current = true; fetched.current = true;
fetch(`${getServerURL()}/auth/me`, { fetch(`${getServerURL()}/auth/me`, {
credentials: "include", credentials: "include",
}) })
.then(async (res) => { .then(async (res) => {
if (!res.ok) { if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`); throw new Error(`auth check failed: ${res.status}`);
} }
const data = (await res.json()) as { user: UserRecord; csrfToken: string }; const data = (await res.json()) as { user: UserRecord; csrfToken: string };
setUser(data.user); setUser(data.user);
setCsrfToken(data.csrfToken); setCsrfToken(data.csrfToken);
}) })
.catch(() => { .catch(() => {
setUserState(null); setUserState(null);
clearAuth(); clearAuth();
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
}, [setUser]); }, [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 }) { export function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useSession(); const { user, isLoading } = useSession();
const location = useLocation(); const location = useLocation();
if (isLoading) { if (isLoading) {
return <Loading message={"Checking authentication"} />; return <Loading message={"Checking authentication"} />;
} }
if (!user) { if (!user) {
const next = encodeURIComponent(location.pathname + location.search); const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?next=${next}`} replace />; 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"; import { cn, DARK_TEXT_COLOUR, isLight } from "@/lib/utils";
export default function SmallSprintDisplay({ export default function SmallSprintDisplay({
sprint, sprint,
className, className,
}: { }: {
sprint?: SprintRecord; sprint?: SprintRecord;
className?: string; className?: string;
}) { }) {
const colour = sprint?.color || DEFAULT_SPRINT_COLOUR; const colour = sprint?.color || DEFAULT_SPRINT_COLOUR;
const textColour = isLight(colour) ? DARK_TEXT_COLOUR : "var(--foreground)"; const textColour = isLight(colour) ? DARK_TEXT_COLOUR : "var(--foreground)";
return ( return (
<div <div
className={cn( className={cn(
"text-xs px-1.5 rounded-full inline-flex whitespace-nowrap border border-foreground/10", "text-xs px-1.5 rounded-full inline-flex whitespace-nowrap border border-foreground/10",
className, className,
)} )}
style={{ backgroundColor: colour, color: textColour }} style={{ backgroundColor: colour, color: textColour }}
> >
{sprint?.name || "None"} {sprint?.name || "None"}
</div> </div>
); );
} }

View File

@@ -3,16 +3,16 @@ import Avatar from "@/components/avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function SmallUserDisplay({ user, className }: { user: UserRecord; className?: string }) { export default function SmallUserDisplay({ user, className }: { user: UserRecord; className?: string }) {
return ( return (
<div className={cn("flex gap-2 items-center", className)}> <div className={cn("flex gap-2 items-center", className)}>
<Avatar <Avatar
name={user.name} name={user.name}
username={user.username} username={user.username}
avatarURL={user.avatarURL} avatarURL={user.avatarURL}
size={6} size={6}
textClass="text-xs" textClass="text-xs"
/> />
{user.name} {user.name}
</div> </div>
); );
} }

View File

@@ -6,12 +6,12 @@ import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
import ColourPicker from "@/components/ui/colour-picker"; import ColourPicker from "@/components/ui/colour-picker";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -23,317 +23,316 @@ import { cn } from "@/lib/utils";
const SPRINT_NAME_MAX_LENGTH = 64; const SPRINT_NAME_MAX_LENGTH = 64;
const getStartOfDay = (date: Date) => { const getStartOfDay = (date: Date) => {
const next = new Date(date); const next = new Date(date);
next.setHours(0, 0, 0, 0); next.setHours(0, 0, 0, 0);
return next; return next;
}; };
const getEndOfDay = (date: Date) => { const getEndOfDay = (date: Date) => {
const next = new Date(date); const next = new Date(date);
next.setHours(23, 59, 0, 0); next.setHours(23, 59, 0, 0);
return next; return next;
}; };
const addDays = (date: Date, days: number) => { const addDays = (date: Date, days: number) => {
const next = new Date(date); const next = new Date(date);
next.setDate(next.getDate() + days); next.setDate(next.getDate() + days);
return next; return next;
}; };
const getDefaultDates = () => { const getDefaultDates = () => {
const today = new Date(); const today = new Date();
return { return {
start: getStartOfDay(today), start: getStartOfDay(today),
end: getEndOfDay(addDays(today, 14)), end: getEndOfDay(addDays(today, 14)),
}; };
}; };
export function SprintForm({ export function SprintForm({
projectId, projectId,
sprints, sprints,
trigger, trigger,
completeAction, completeAction,
mode = "create", mode = "create",
existingSprint, existingSprint,
open: controlledOpen, open: controlledOpen,
onOpenChange: controlledOnOpenChange, onOpenChange: controlledOnOpenChange,
}: { }: {
projectId?: number; projectId?: number;
sprints: SprintRecord[]; sprints: SprintRecord[];
trigger?: React.ReactNode; trigger?: React.ReactNode;
completeAction?: (sprint: SprintRecord) => void | Promise<void>; completeAction?: (sprint: SprintRecord) => void | Promise<void>;
mode?: "create" | "edit"; mode?: "create" | "edit";
existingSprint?: SprintRecord; existingSprint?: SprintRecord;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
}) { }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const createSprint = useCreateSprint(); const createSprint = useCreateSprint();
const updateSprint = useUpdateSprint(); const updateSprint = useUpdateSprint();
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? controlledOpen : internalOpen; const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen; const setOpen = isControlled ? (controlledOnOpenChange ?? (() => {})) : setInternalOpen;
const { start, end } = getDefaultDates(); const { start, end } = getDefaultDates();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [colour, setColour] = useState(DEFAULT_SPRINT_COLOUR); const [colour, setColour] = useState(DEFAULT_SPRINT_COLOUR);
const [startDate, setStartDate] = useState(start); const [startDate, setStartDate] = useState(start);
const [endDate, setEndDate] = useState(end); const [endDate, setEndDate] = useState(end);
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isEdit = mode === "edit"; const isEdit = mode === "edit";
useEffect(() => { useEffect(() => {
if (isEdit && existingSprint && open) { if (isEdit && existingSprint && open) {
setName(existingSprint.name); setName(existingSprint.name);
setColour(existingSprint.color); setColour(existingSprint.color);
setStartDate(new Date(existingSprint.startDate)); setStartDate(new Date(existingSprint.startDate));
setEndDate(new Date(existingSprint.endDate)); setEndDate(new Date(existingSprint.endDate));
} }
}, [isEdit, existingSprint, open]); }, [isEdit, existingSprint, open]);
const dateError = useMemo(() => { const dateError = useMemo(() => {
if (!submitAttempted) return ""; if (!submitAttempted) return "";
if (startDate > endDate) { if (startDate > endDate) {
return "End date must be after start date"; return "End date must be after start date";
} }
return ""; return "";
}, [endDate, startDate, submitAttempted]); }, [endDate, startDate, submitAttempted]);
const reset = () => { const reset = () => {
const defaults = getDefaultDates(); const defaults = getDefaultDates();
setName(""); setName("");
setColour(DEFAULT_SPRINT_COLOUR); setColour(DEFAULT_SPRINT_COLOUR);
setStartDate(defaults.start); setStartDate(defaults.start);
setEndDate(defaults.end); setEndDate(defaults.end);
setSubmitAttempted(false); setSubmitAttempted(false);
setSubmitting(false); setSubmitting(false);
setError(null); setError(null);
}; };
const onOpenChange = (nextOpen: boolean) => { const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen); setOpen(nextOpen);
if (!nextOpen) { if (!nextOpen) {
reset(); reset();
} }
}; };
const handleSubmit = async (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
setSubmitAttempted(true); setSubmitAttempted(true);
if (name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) { if (name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) {
return; 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 (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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild> {dialogContent}
{trigger || ( </Dialog>
<Button variant="outline" disabled={!projectId}>
Create Sprint
</Button>
)}
</DialogTrigger>
{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"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function SprintSelect({ export function SprintSelect({
sprints, sprints,
value, value,
onChange, onChange,
placeholder = "Select sprint", placeholder = "Select sprint",
}: { }: {
sprints: SprintRecord[]; sprints: SprintRecord[];
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
fallbackUser?: UserRecord | null; fallbackUser?: UserRecord | null;
placeholder?: string; placeholder?: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}> <Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
<SelectTrigger <SelectTrigger
className="group w-auto flex items-center -mt-1" className="group w-auto flex items-center -mt-1"
variant="unstyled" variant="unstyled"
chevronClassName="hidden" chevronClassName="hidden"
isOpen={isOpen} isOpen={isOpen}
> >
<SelectValue placeholder={placeholder} className="hover:opacity-85" /> <SelectValue placeholder={placeholder} className="hover:opacity-85" />
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
side="bottom" side="bottom"
position="popper" position="popper"
className="data-[side=bottom]:translate-y-1 data-[side=bottom]:translate-x-1" className="data-[side=bottom]:translate-y-1 data-[side=bottom]:translate-x-1"
> >
<SelectItem value="unassigned"> <SelectItem value="unassigned">
<SmallSprintDisplay /> <SmallSprintDisplay />
</SelectItem> </SelectItem>
{sprints.map((sprint) => ( {sprints.map((sprint) => (
<SelectItem key={sprint.id} value={sprint.id.toString()}> <SelectItem key={sprint.id} value={sprint.id.toString()}>
<SmallSprintDisplay sprint={sprint} /> <SmallSprintDisplay sprint={sprint} />
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
); );
} }

View File

@@ -4,41 +4,41 @@ import StatusTag from "@/components/status-tag";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function StatusSelect({ export function StatusSelect({
statuses, statuses,
value, value,
onChange, onChange,
placeholder = "Select status", placeholder = "Select status",
trigger, trigger,
}: { }: {
statuses: Record<string, string>; statuses: Record<string, string>;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
placeholder?: string; placeholder?: string;
trigger?: (args: { isOpen: boolean; value: string }) => ReactNode; trigger?: (args: { isOpen: boolean; value: string }) => ReactNode;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}> <Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
{trigger ? ( {trigger ? (
trigger({ isOpen, value }) trigger({ isOpen, value })
) : ( ) : (
<SelectTrigger <SelectTrigger
className="w-fit px-2 text-xs gap-1" className="w-fit px-2 text-xs gap-1"
size="sm" size="sm"
chevronClassName={"size-3 -mr-1"} chevronClassName={"size-3 -mr-1"}
isOpen={isOpen} isOpen={isOpen}
> >
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
)} )}
<SelectContent side="bottom" position="popper" align="start"> <SelectContent side="bottom" position="popper" align="start">
{Object.entries(statuses).map(([status, colour]) => ( {Object.entries(statuses).map(([status, colour]) => (
<SelectItem key={status} value={status} textClassName="text-xs"> <SelectItem key={status} value={status} textClassName="text-xs">
<StatusTag status={status} colour={colour} /> <StatusTag status={status} colour={colour} />
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
); );
} }

View File

@@ -2,25 +2,25 @@ import { DEFAULT_STATUS_COLOUR } from "@sprint/shared";
import { cn, DARK_TEXT_COLOUR, isLight } from "@/lib/utils"; import { cn, DARK_TEXT_COLOUR, isLight } from "@/lib/utils";
export default function StatusTag({ export default function StatusTag({
status, status,
colour = DEFAULT_STATUS_COLOUR, colour = DEFAULT_STATUS_COLOUR,
className, className,
}: { }: {
status: string; status: string;
colour: string; colour: string;
className?: string; className?: string;
}) { }) {
const textColour = isLight(colour) ? DARK_TEXT_COLOUR : "var(--foreground)"; const textColour = isLight(colour) ? DARK_TEXT_COLOUR : "var(--foreground)";
return ( return (
<div <div
className={cn( className={cn(
"text-xs px-1 rounded inline-flex whitespace-nowrap border border-foreground/10", "text-xs px-1 rounded inline-flex whitespace-nowrap border border-foreground/10",
className, className,
)} )}
style={{ backgroundColor: colour, color: textColour }} style={{ backgroundColor: colour, color: textColour }}
> >
{status} {status}
</div> </div>
); );
} }

View File

@@ -3,67 +3,67 @@ import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system"; type Theme = "dark" | "light" | "system";
type ThemeProviderProps = { type ThemeProviderProps = {
children: React.ReactNode; children: React.ReactNode;
defaultTheme?: Theme; defaultTheme?: Theme;
storageKey?: string; storageKey?: string;
}; };
type ThemeProviderState = { type ThemeProviderState = {
theme: Theme; theme: Theme;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
}; };
const initialState: ThemeProviderState = { const initialState: ThemeProviderState = {
theme: "system", theme: "system",
setTheme: () => null, setTheme: () => null,
}; };
const ThemeProviderContext = createContext<ThemeProviderState>(initialState); const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({ export function ThemeProvider({
children, children,
defaultTheme = "system", defaultTheme = "system",
storageKey = "vite-ui-theme", storageKey = "vite-ui-theme",
...props ...props
}: ThemeProviderProps) { }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>( const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme, () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
); );
useEffect(() => { useEffect(() => {
const root = window.document.documentElement; const root = window.document.documentElement;
root.classList.remove("light", "dark"); root.classList.remove("light", "dark");
if (theme === "system") { if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
root.classList.add(systemTheme); root.classList.add(systemTheme);
return; return;
} }
root.classList.add(theme); root.classList.add(theme);
}, [theme]); }, [theme]);
const value = { const value = {
theme, theme,
setTheme: (theme: Theme) => { setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme); localStorage.setItem(storageKey, theme);
setTheme(theme); setTheme(theme);
}, },
}; };
return ( return (
<ThemeProviderContext.Provider {...props} value={value}> <ThemeProviderContext.Provider {...props} value={value}>
{children} {children}
</ThemeProviderContext.Provider> </ThemeProviderContext.Provider>
); );
} }
export const useTheme = () => { 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"; import { cn } from "@/lib/utils";
function ThemeToggle({ withText, className }: { withText?: boolean; className?: string }) { function ThemeToggle({ withText, className }: { withText?: boolean; className?: string }) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const resolvedTheme = const resolvedTheme =
theme === "system" theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches ? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark" ? "dark"
: "light" : "light"
: theme; : theme;
const isDark = resolvedTheme === "dark"; const isDark = resolvedTheme === "dark";
return ( return (
<div className={cn("flex items-center gap-2", className)}> <div className={cn("flex items-center gap-2", className)}>
<IconButton <IconButton
size="md" size="md"
className={cn("hover:text-muted-foreground", className)} className={cn("hover:text-muted-foreground", className)}
onClick={() => setTheme(isDark ? "light" : "dark")} onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "Switch to light mode" : "Switch to dark mode"} title={isDark ? "Switch to light mode" : "Switch to dark mode"}
> >
{isDark ? <Icon icon="sun" className="size-5" /> : <Icon icon="moon" className="size-5" />} {isDark ? <Icon icon="sun" className="size-5" /> : <Icon icon="moon" className="size-5" />}
</IconButton> </IconButton>
{withText && (isDark ? "Dark Mode" : "Light Mode")} {withText && (isDark ? "Dark Mode" : "Light Mode")}
</div> </div>
); );
} }
export default ThemeToggle; export default ThemeToggle;

View File

@@ -9,59 +9,59 @@ const FALLBACK_TIME = "--:--:--";
const REFRESH_INTERVAL_MS = 10000; const REFRESH_INTERVAL_MS = 10000;
export function getWorkTimeMs(timestamps: string[] | undefined): number { export function getWorkTimeMs(timestamps: string[] | undefined): number {
if (!timestamps?.length) return 0; if (!timestamps?.length) return 0;
const dates = timestamps.map((t) => new Date(t)); const dates = timestamps.map((t) => new Date(t));
return calculateWorkTimeMs(dates); return calculateWorkTimeMs(dates);
} }
export function TimerDisplay({ issueId }: { issueId: number }) { export function TimerDisplay({ issueId }: { issueId: number }) {
const { data: timerState, error: timerError } = useTimerState(issueId, { const { data: timerState, error: timerError } = useTimerState(issueId, {
refetchInterval: REFRESH_INTERVAL_MS, refetchInterval: REFRESH_INTERVAL_MS,
}); });
const { data: inactiveTimers = [], error: inactiveError } = useInactiveTimers(issueId, { const { data: inactiveTimers = [], error: inactiveError } = useInactiveTimers(issueId, {
refetchInterval: REFRESH_INTERVAL_MS, refetchInterval: REFRESH_INTERVAL_MS,
}); });
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const combinedError = timerError ?? inactiveError; const combinedError = timerError ?? inactiveError;
useEffect(() => { useEffect(() => {
if (combinedError) { if (combinedError) {
const message = parseError(combinedError as Error); const message = parseError(combinedError as Error);
setError(message); setError(message);
toast.error(`Error fetching timer data: ${message}`, { toast.error(`Error fetching timer data: ${message}`, {
dismissible: false, dismissible: false,
}); });
return; return;
} }
setError(null); setError(null);
}, [combinedError]); }, [combinedError]);
useEffect(() => { useEffect(() => {
if (!timerState?.isRunning) return; if (!timerState?.isRunning) return;
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
setTick((t) => t + 1); setTick((t) => t + 1);
}, 1000); }, 1000);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [timerState?.isRunning]); }, [timerState?.isRunning]);
const inactiveWorkTimeMs = useMemo( const inactiveWorkTimeMs = useMemo(
() => inactiveTimers.reduce((total, session) => total + getWorkTimeMs(session?.timestamps), 0), () => inactiveTimers.reduce((total, session) => total + getWorkTimeMs(session?.timestamps), 0),
[inactiveTimers], [inactiveTimers],
); );
void tick; void tick;
const currentWorkTimeMs = getWorkTimeMs(timerState?.timestamps); const currentWorkTimeMs = getWorkTimeMs(timerState?.timestamps);
const totalWorkTimeMs = inactiveWorkTimeMs + currentWorkTimeMs; const totalWorkTimeMs = inactiveWorkTimeMs + currentWorkTimeMs;
const displayWorkTime = error ? FALLBACK_TIME : formatTime(totalWorkTimeMs); const displayWorkTime = error ? FALLBACK_TIME : formatTime(totalWorkTimeMs);
return ( return (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono tabular-nums">{displayWorkTime}</span> <span className="font-mono tabular-nums">{displayWorkTime}</span>
</div> </div>
); );
} }

View File

@@ -5,19 +5,19 @@ import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
export function TimerModal({ issueId, disabled }: { issueId: number; disabled?: boolean }) { export function TimerModal({ issueId, disabled }: { issueId: number; disabled?: boolean }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild disabled={disabled}> <DialogTrigger asChild disabled={disabled}>
<Button variant="outline" size="sm" disabled={disabled}> <Button variant="outline" size="sm" disabled={disabled}>
<Icon icon="timer" className="size-4" /> <Icon icon="timer" className="size-4" />
Timer Timer
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="w-xs" showCloseButton={false}> <DialogContent className="w-xs" showCloseButton={false}>
<IssueTimer issueId={issueId} onEnd={() => setOpen(false)} /> <IssueTimer issueId={issueId} onEnd={() => setOpen(false)} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -12,11 +12,11 @@ import { useAuthenticatedSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display"; import SmallUserDisplay from "@/components/small-user-display";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button"; import { IconButton } from "@/components/ui/icon-button";
@@ -24,79 +24,79 @@ import { BREATHING_ROOM } from "@/lib/layout";
import { useOrganisations } from "@/lib/query/hooks"; import { useOrganisations } from "@/lib/query/hooks";
export default function TopBar({ showIssueForm = true }: { showIssueForm?: boolean }) { export default function TopBar({ showIssueForm = true }: { showIssueForm?: boolean }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const { selectedOrganisationId, selectedProjectId } = useSelection(); const { selectedOrganisationId, selectedProjectId } = useSelection();
const { data: organisationsData = [] } = useOrganisations(); const { data: organisationsData = [] } = useOrganisations();
const selectedOrganisation = useMemo( const selectedOrganisation = useMemo(
() => organisationsData.find((org) => org.Organisation.id === selectedOrganisationId) ?? null, () => organisationsData.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
[organisationsData, selectedOrganisationId], [organisationsData, selectedOrganisationId],
); );
return ( return (
<div className="flex gap-12 items-center justify-between"> <div className="flex gap-12 items-center justify-between">
<div className={`flex gap-${BREATHING_ROOM} items-center`}> <div className={`flex gap-${BREATHING_ROOM} items-center`}>
<OrganisationSelect <OrganisationSelect
noDecoration noDecoration
triggerClassName="px-1 rounded-full hover:bg-transparent dark:hover:bg-transparent" triggerClassName="px-1 rounded-full hover:bg-transparent dark:hover:bg-transparent"
trigger={ trigger={
<OrgIcon <OrgIcon
name={selectedOrganisation?.Organisation.name ?? ""} name={selectedOrganisation?.Organisation.name ?? ""}
slug={selectedOrganisation?.Organisation.slug ?? ""} slug={selectedOrganisation?.Organisation.slug ?? ""}
iconURL={selectedOrganisation?.Organisation.iconURL || undefined} iconURL={selectedOrganisation?.Organisation.iconURL || undefined}
size={7} size={7}
/> />
} }
/> />
{selectedOrganisationId && <ProjectSelect showLabel />} {selectedOrganisationId && <ProjectSelect showLabel />}
{selectedOrganisationId && selectedProjectId && showIssueForm && ( {selectedOrganisationId && selectedProjectId && showIssueForm && (
<IssueForm <IssueForm
trigger={ trigger={
<IconButton <IconButton
variant="outline" variant="outline"
className="w-9 h-9" className="w-9 h-9"
title="Create Issue" title="Create Issue"
aria-label="Create issue" aria-label="Create issue"
> >
<Icon icon="plus" /> <Icon icon="plus" />
</IconButton> </IconButton>
} }
/> />
)} )}
</div> </div>
<div className={`flex gap-${BREATHING_ROOM} items-center`}> <div className={`flex gap-${BREATHING_ROOM} items-center`}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger className="text-sm"> <DropdownMenuTrigger className="text-sm">
<SmallUserDisplay user={user} /> <SmallUserDisplay user={user} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem asChild className="flex items-end justify-end"> <DropdownMenuItem asChild className="flex items-end justify-end">
<Account /> <Account />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end"> <DropdownMenuItem asChild className="flex items-end justify-end">
<Organisations /> <Organisations />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end"> <DropdownMenuItem asChild className="flex items-end justify-end">
<ServerConfiguration <ServerConfiguration
trigger={ trigger={
<Button <Button
variant="ghost" variant="ghost"
className="flex w-full items-center justify-end text-end px-2 py-1 m-0 h-auto" className="flex w-full items-center justify-end text-end px-2 py-1 m-0 h-auto"
title="Server Configuration" title="Server Configuration"
> >
Server Configuration Server Configuration
</Button> </Button>
} }
/> />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="flex items-end justify-end p-0 m-0"> <DropdownMenuItem className="flex items-end justify-end p-0 m-0">
<LogOutButton noStyle className="flex w-full justify-end" /> <LogOutButton noStyle className="flex w-full justify-end" />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
); );
} }

View File

@@ -5,73 +5,73 @@ import { Link } from "react-router-dom";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( 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", "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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "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", outline: "bg-transparent border dark:hover:bg-muted/40",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
dummy: "", dummy: "",
}, },
size: { size: {
none: "", none: "",
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 px-6 has-[>svg]:px-4", lg: "h-10 px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",
"icon-sm": "size-8", "icon-sm": "size-8",
"icon-lg": "size-10", "icon-lg": "size-10",
}, },
},
defaultVariants: {
variant: "default",
size: "default",
},
}, },
defaultVariants: {
variant: "default",
size: "default",
},
},
); );
function Button({ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
asChild = false, asChild = false,
linkTo, linkTo,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean;
linkTo?: string; linkTo?: string;
}) { }) {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
return ( return (
<> <>
{linkTo ? ( {linkTo ? (
<Link to={linkTo}> <Link to={linkTo}>
<Comp <Comp
data-slot="button" data-slot="button"
data-variant={variant} data-variant={variant}
data-size={size} data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
</Link> </Link>
) : ( ) : (
<Comp <Comp
data-slot="button" data-slot="button"
data-variant={variant} data-variant={variant}
data-size={size} data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
)} )}
</> </>
); );
} }
export { Button, buttonVariants }; export { Button, buttonVariants };

View File

@@ -6,213 +6,202 @@ import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Calendar({ function Calendar({
className, className,
classNames, classNames,
showOutsideDays = true, showOutsideDays = true,
captionLayout = "label", captionLayout = "label",
buttonVariant = "ghost", buttonVariant = "ghost",
formatters, formatters,
components, components,
sprints, sprints,
isEnd, isEnd,
...props ...props
}: React.ComponentProps<typeof DayPicker> & { }: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]; buttonVariant?: React.ComponentProps<typeof Button>["variant"];
sprints?: SprintRecord[]; sprints?: SprintRecord[];
isEnd?: boolean; isEnd?: boolean;
}) { }) {
const defaultClassNames = getDefaultClassNames(); const defaultClassNames = getDefaultClassNames();
return ( return (
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
className={cn( className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", "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\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className, className,
)} )}
captionLayout={captionLayout} captionLayout={captionLayout}
formatters={{ formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }), formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters, ...formatters,
}} }}
classNames={{ classNames={{
root: cn("w-fit", defaultClassNames.root), root: cn("w-fit", defaultClassNames.root),
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months), months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month), month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn( nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav, defaultClassNames.nav,
), ),
button_previous: cn( button_previous: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous, defaultClassNames.button_previous,
), ),
button_next: cn( button_next: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next, defaultClassNames.button_next,
), ),
month_caption: cn( month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption, defaultClassNames.month_caption,
), ),
dropdowns: cn( dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns, defaultClassNames.dropdowns,
), ),
dropdown_root: cn( dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px]", "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px]",
defaultClassNames.dropdown_root, defaultClassNames.dropdown_root,
), ),
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown), dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn( caption_label: cn(
"select-none font-medium", "select-none font-medium",
captionLayout === "label" captionLayout === "label"
? "text-sm" ? "text-sm"
: "pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", : "pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label, defaultClassNames.caption_label,
), ),
table: "w-full border-collapse", table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground flex-1 font-normal text-[0.8rem] select-none", "text-muted-foreground flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday, defaultClassNames.weekday,
), ),
week: cn("flex w-full mt-2", defaultClassNames.week), week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header), week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
week_number: cn( week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number),
"text-[0.8rem] select-none text-muted-foreground", day: cn(
defaultClassNames.week_number, "relative w-full h-full p-0 text-center group/day aspect-square select-none",
), defaultClassNames.day,
day: cn( ),
"relative w-full h-full p-0 text-center group/day aspect-square select-none", range_start: cn("bg-accent", defaultClassNames.range_start),
defaultClassNames.day, range_middle: cn(defaultClassNames.range_middle),
), range_end: cn("bg-accent", defaultClassNames.range_end),
range_start: cn("bg-accent", defaultClassNames.range_start), today: cn("border border-dashed -m-px", defaultClassNames.today),
range_middle: cn(defaultClassNames.range_middle), outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside),
range_end: cn("bg-accent", defaultClassNames.range_end), disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
today: cn("border border-dashed -m-px", defaultClassNames.today), hidden: cn("invisible", defaultClassNames.hidden),
outside: cn( ...classNames,
"text-muted-foreground aria-selected:text-muted-foreground", }}
defaultClassNames.outside, components={{
), Root: ({ className, rootRef, ...props }) => {
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled), return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
hidden: cn("invisible", defaultClassNames.hidden), },
...classNames, Chevron: ({ className, orientation, ...props }) => {
}} if (orientation === "left") {
components={{ return <Icon icon="chevronLeftIcon" className={cn("size-4", className)} {...props} />;
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") { if (orientation === "right") {
return ( return <Icon icon="chevronRightIcon" className={cn("size-4", className)} {...props} />;
<Icon icon="chevronRightIcon" className={cn("size-4", className)} {...props} /> }
);
}
return <Icon icon="chevronDownIcon" className={cn("size-4", className)} {...props} />; return <Icon icon="chevronDownIcon" className={cn("size-4", className)} {...props} />;
}, },
DayButton: (props) => <CalendarDayButton {...props} sprints={sprints} isEnd={isEnd} />, DayButton: (props) => <CalendarDayButton {...props} sprints={sprints} isEnd={isEnd} />,
WeekNumber: ({ children, ...props }) => { WeekNumber: ({ children, ...props }) => {
return ( return (
<td {...props}> <td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center"> <div className="flex size-(--cell-size) items-center justify-center text-center">
{children} {children}
</div> </div>
</td> </td>
); );
}, },
...components, ...components,
}} }}
{...props} {...props}
/> />
); );
} }
function CalendarDayButton({ function CalendarDayButton({
className, className,
day, day,
modifiers, modifiers,
sprints, sprints,
style, style,
disabled, disabled,
isEnd, isEnd,
...props ...props
}: React.ComponentProps<typeof DayButton> & { sprints?: SprintRecord[]; isEnd?: boolean }) { }: React.ComponentProps<typeof DayButton> & { sprints?: SprintRecord[]; isEnd?: boolean }) {
const defaultClassNames = getDefaultClassNames(); const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null); const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (modifiers.focused) ref.current?.focus(); if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]); }, [modifiers.focused]);
let isDisabled = false; let isDisabled = false;
let sprint: SprintRecord | null = null; let sprint: SprintRecord | null = null;
for (const entry of sprints || []) { for (const entry of sprints || []) {
if (day.date >= new Date(entry.startDate) && day.date <= new Date(entry.endDate)) { if (day.date >= new Date(entry.startDate) && day.date <= new Date(entry.endDate)) {
isDisabled = true; isDisabled = true;
sprint = entry; sprint = entry;
}
} }
}
return ( return (
<Button <Button
ref={ref} ref={ref}
variant="ghost" variant="ghost"
size="icon" size="icon"
data-day={day.date.toLocaleDateString()} data-day={day.date.toLocaleDateString()}
data-selected-single={ data-selected-single={
modifiers.selected && modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
!modifiers.range_start && }
!modifiers.range_end && data-range-start={modifiers.range_start}
!modifiers.range_middle data-range-end={modifiers.range_end}
} data-range-middle={modifiers.range_middle}
data-range-start={modifiers.range_start} className={cn(
data-range-end={modifiers.range_end} "flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal",
data-range-middle={modifiers.range_middle} "[&>span]:text-xs [&>span]:opacity-70",
className={cn( !sprint?.color && "hover:bg-primary/90 hover:text-foreground",
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal", "data-[selected-single=true]:!bg-foreground data-[selected-single=true]:!text-background data-[selected-single=true]:hover:!bg-foreground/90",
"[&>span]:text-xs [&>span]:opacity-70", "data-[range-start=true]:!bg-foreground data-[range-start=true]:!text-background",
!sprint?.color && "hover:bg-primary/90 hover:text-foreground", "data-[range-middle=true]:!bg-foreground data-[range-middle=true]:!text-background",
"data-[selected-single=true]:!bg-foreground data-[selected-single=true]:!text-background data-[selected-single=true]:hover:!bg-foreground/90", "data-[range-end=true]:!bg-foreground data-[range-end=true]:!text-background",
"data-[range-start=true]:!bg-foreground data-[range-start=true]:!text-background", sprint?.color && "border-t border-b !border-(--sprint-color) !bg-(--sprint-color)/5",
"data-[range-middle=true]:!bg-foreground data-[range-middle=true]:!text-background", defaultClassNames.day,
"data-[range-end=true]:!bg-foreground data-[range-end=true]:!text-background", className,
sprint?.color && "border-t border-b !border-(--sprint-color) !bg-(--sprint-color)/5", )}
defaultClassNames.day, style={
className, {
)} ...style,
style={ "--sprint-color": sprint?.color ? sprint.color : null,
{ borderLeft:
...style, sprint && day.date.getUTCDate() === new Date(sprint.startDate).getUTCDate()
"--sprint-color": sprint?.color ? sprint.color : null, ? `1px solid ${sprint?.color}`
borderLeft: : day.date.getDay() === 0 // sunday (left side)
sprint && day.date.getUTCDate() === new Date(sprint.startDate).getUTCDate() ? `1px dashed ${sprint?.color}`
? `1px solid ${sprint?.color}` : `0px`,
: day.date.getDay() === 0 // sunday (left side) borderRight:
? `1px dashed ${sprint?.color}` sprint && day.date.getUTCDate() === new Date(sprint.endDate).getUTCDate()
: `0px`, ? `1px solid ${sprint?.color}`
borderRight: : day.date.getDay() === 6 // saturday (right side)
sprint && day.date.getUTCDate() === new Date(sprint.endDate).getUTCDate() ? `1px dashed ${sprint?.color}`
? `1px solid ${sprint?.color}` : `0px`,
: day.date.getDay() === 6 // saturday (right side) } as React.CSSProperties
? `1px dashed ${sprint?.color}` }
: `0px`, disabled={isDisabled || disabled}
} as React.CSSProperties {...props}
} />
disabled={isDisabled || disabled} );
{...props}
/>
);
} }
export { Calendar, CalendarDayButton }; export { Calendar, CalendarDayButton };

View File

@@ -5,39 +5,35 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function ColourPicker({ export default function ColourPicker({
colour, colour,
onChange, onChange,
asChild = true, asChild = true,
className, className,
}: { }: {
colour: string; colour: string;
onChange: (value: string) => void; onChange: (value: string) => void;
asChild?: boolean; asChild?: boolean;
className?: string; className?: string;
}) { }) {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild={asChild}> <PopoverTrigger asChild={asChild}>
<Button <Button type="button" className={cn("w-8 h-8", className)} style={{ backgroundColor: colour }} />
type="button" </PopoverTrigger>
className={cn("w-8 h-8", className)} <PopoverContent className="w-fit grid gap-2 p-2" align="start" side={"top"}>
style={{ backgroundColor: colour }} <HexColorPicker color={colour} onChange={onChange} className="p-0 m-0" />
/> <div className="border w-[92px] inline-flex items-center">
</PopoverTrigger> <Input
<PopoverContent className="w-fit grid gap-2 p-2" align="start" side={"top"}> value={colour.slice(1).toUpperCase()}
<HexColorPicker color={colour} onChange={onChange} className="p-0 m-0" /> onChange={(e) => onChange(`#${e.target.value}`)}
<div className="border w-[92px] inline-flex items-center"> spellCheck={false}
<Input className="flex-1 border-transparent h-fit pl-0 mx-0"
value={colour.slice(1).toUpperCase()} maxLength={6}
onChange={(e) => onChange(`#${e.target.value}`)} showCounter={false}
spellCheck={false} showHashPrefix
className="flex-1 border-transparent h-fit pl-0 mx-0" />
maxLength={6} </div>
showCounter={false} </PopoverContent>
showHashPrefix </Popover>
/> );
</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"; import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
onOpenChange, onOpenChange,
onConfirm, onConfirm,
title, title,
message, message,
processingText = "Processing...", processingText = "Processing...",
confirmText = "Confirm", confirmText = "Confirm",
cancelText = "Cancel", cancelText = "Cancel",
variant = "default", variant = "default",
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onConfirm: () => void; onConfirm: () => void;
title: string; title: string;
message: string; message: string;
processingText?: string; processingText?: string;
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
variant?: "default" | "destructive"; variant?: "default" | "destructive";
}) { }) {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const handleConfirm = async () => { const handleConfirm = async () => {
setSubmitting(true); setSubmitting(true);
try { try {
await onConfirm(); await onConfirm();
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-sm text-muted-foreground">{message}</p> <p className="text-sm text-muted-foreground">{message}</p>
<div className="flex gap-2 justify-end mt-4"> <div className="flex gap-2 justify-end mt-4">
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline" type="button"> <Button variant="outline" type="button">
{cancelText} {cancelText}
</Button> </Button>
</DialogClose> </DialogClose>
<Button variant={variant} onClick={handleConfirm} disabled={submitting}> <Button variant={variant} onClick={handleConfirm} disabled={submitting}>
{submitting ? processingText : confirmText} {submitting ? processingText : confirmText}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -5,133 +5,133 @@ import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { 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>) { 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>) { 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>) { 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>) { function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn("fixed inset-0 z-50 bg-black/50", className)} className={cn("fixed inset-0 z-50 bg-black/50", className)}
{...props} {...props}
/> />
); );
} }
function DialogContent({ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
closePos = "top-right", closePos = "top-right",
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean; showCloseButton?: boolean;
closePos?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; closePos?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=closed]:zoom-out-95", "bg-background data-[state=closed]:zoom-out-95",
"data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%]", "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%]", "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", "gap-4 border p-4 shadow-lg duration-200 outline-none w-sm",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
className={cn( className={cn(
"cursor-pointer ring-offset-background focus:ring-ring", "cursor-pointer ring-offset-background focus:ring-ring",
"data-[state=open]:bg-accent data-[state=open]:text-muted-foreground", "data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
"absolute opacity-70", "absolute opacity-70",
closePos === "top-left" && "top-4 left-4", closePos === "top-left" && "top-4 left-4",
closePos === "top-right" && "top-4 right-4", closePos === "top-right" && "top-4 right-4",
closePos === "bottom-left" && "bottom-4 left-4", closePos === "bottom-left" && "bottom-4 left-4",
closePos === "bottom-right" && "bottom-4 right-4", closePos === "bottom-right" && "bottom-4 right-4",
"hover:opacity-100 focus:ring-2 focus:ring-offset-2 ", "hover:opacity-100 focus:ring-2 focus:ring-offset-2 ",
"ocus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none", "ocus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none",
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
)} )}
> >
<Icon icon="x" /> <Icon icon="x" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
); );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
); );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props} {...props}
/> />
); );
} }
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) { function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
); );
} }
function DialogDescription({ function DialogDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) { }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
); );
} }
export { export {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
}; };

View File

@@ -4,286 +4,286 @@ import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { 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>) { 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({ function DropdownMenuTrigger({
className, className,
size = "default", size = "default",
noStyle = false, noStyle = false,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger> & {
size?: "sm" | "default"; size?: "sm" | "default";
noStyle?: boolean; noStyle?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Trigger <DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
data-size={size} data-size={size}
className={ className={
noStyle noStyle
? cn(className) ? cn(className)
: cn( : cn(
"cursor-pointer border data-[placeholder]:text-muted-foreground", "cursor-pointer border data-[placeholder]:text-muted-foreground",
"[&_svg:not([class*='text-'])]:text-foreground", "[&_svg:not([class*='text-'])]:text-foreground",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
"aria-invalid:border-destructive dark:hover:bg-muted/40", "aria-invalid:border-destructive dark:hover:bg-muted/40",
"flex w-fit items-center justify-between gap-2 border", "flex w-fit items-center justify-between gap-2 border",
"bg-transparent px-3 py-2 text-sm whitespace-nowrap", "bg-transparent px-3 py-2 text-sm whitespace-nowrap",
"shadow-xs outline-none disabled:cursor-not-allowed", "shadow-xs outline-none disabled:cursor-not-allowed",
"disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8", "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]:line-clamp-1 *:data-[slot=select-value]:flex",
"*:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2", "*: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", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
) )
} }
{...props} {...props}
/> />
); );
} }
function DropdownMenuContent({ function DropdownMenuContent({
className, className,
sideOffset = 0, sideOffset = 0,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return ( return (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground", "bg-popover text-popover-foreground",
"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95", "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-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2",
"data-[side=left]:slide-in-from-right-2", "data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2", "data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2", "data-[side=top]:slide-in-from-bottom-2",
"z-50 max-h-(--radix-dropdown-menu-content-available-height)", "z-50 max-h-(--radix-dropdown-menu-content-available-height)",
"min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin)", "min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin)",
"overflow-x-hidden overflow-y-auto border p-1 shadow-md", "overflow-x-hidden overflow-y-auto border p-1 shadow-md",
"data-[side=bottom]:translate-y-1", "data-[side=bottom]:translate-y-1",
className, className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
); );
} }
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { 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({ function DropdownMenuItem({
className, className,
inset, inset,
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean;
variant?: "default" | "destructive"; variant?: "default" | "destructive";
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item" data-slot="dropdown-menu-item"
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent/40 focus:text-accent-foreground", "focus:bg-accent/40 focus:text-accent-foreground",
"data-[variant=destructive]:text-destructive", "data-[variant=destructive]:text-destructive",
"data-[variant=destructive]:focus:bg-destructive/10", "data-[variant=destructive]:focus:bg-destructive/10",
"dark:data-[variant=destructive]:focus:bg-destructive/20", "dark:data-[variant=destructive]:focus:bg-destructive/20",
"data-[variant=destructive]:focus:text-destructive", "data-[variant=destructive]:focus:text-destructive",
"data-[variant=destructive]:*:[svg]:!text-destructive", "data-[variant=destructive]:*:[svg]:!text-destructive",
"[&_svg:not([class*='text-'])]:text-foreground relative", "[&_svg:not([class*='text-'])]:text-foreground relative",
"flex w-full cursor-pointer items-center gap-2", "flex w-full cursor-pointer items-center gap-2",
"px-2 py-1 text-sm outline-hidden select-none", "px-2 py-1 text-sm outline-hidden select-none",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"data-[inset]:pl-8 [&_svg]:pointer-events-none", "data-[inset]:pl-8 [&_svg]:pointer-events-none",
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
className, className,
children, children,
checked, checked,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return ( return (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative", "focus:bg-accent focus:text-accent-foreground relative",
"flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8", "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", "text-sm outline-hidden select-none data-[disabled]:pointer-events-none",
"data-[disabled]:opacity-50 [&_svg]:pointer-events-none", "data-[disabled]:opacity-50 [&_svg]:pointer-events-none",
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Icon icon="checkIcon" className="size-4" /> <Icon icon="checkIcon" className="size-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
); );
} }
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { 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({ function DropdownMenuRadioItem({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return ( return (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative", "focus:bg-accent focus:text-accent-foreground relative",
"flex cursor-default items-center gap-2 py-1.5 pr-2 pl-8", "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", "text-sm outline-hidden select-none data-[disabled]:pointer-events-none",
"data-[disabled]:opacity-50 [&_svg]:pointer-events-none", "data-[disabled]:opacity-50 [&_svg]:pointer-events-none",
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Icon icon="circleIcon" className="size-2 fill-current" /> <Icon icon="circleIcon" className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
); );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
className, className,
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean; inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn("px-2 py-0 text-sm font-medium data-[inset]:pl-8", className)} className={cn("px-2 py-0 text-sm font-medium data-[inset]:pl-8", className)}
{...props} {...props}
/> />
); );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
); );
} }
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props} {...props}
/> />
); );
} }
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { 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({ function DropdownMenuSubTrigger({
className, className,
inset, inset,
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean; inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent",
"data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-foreground", "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", "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", "data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<Icon icon="chevronRightIcon" className="ml-auto size-4" /> <Icon icon="chevronRightIcon" className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
); );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in", "bg-popover text-popover-foreground data-[state=open]:animate-in",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0", "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]:fade-in-0 data-[state=closed]:zoom-out-95",
"data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2", "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=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]", "data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem]",
"origin-(--radix-dropdown-menu-content-transform-origin)", "origin-(--radix-dropdown-menu-content-transform-origin)",
"overflow-hidden border p-1 shadow-lg", "overflow-hidden border p-1 shadow-lg",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
}; };

View File

@@ -3,73 +3,73 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
export function Field({ export function Field({
label, label,
value = "", value = "",
onChange = () => {}, onChange = () => {},
validate, validate,
hidden = false, hidden = false,
submitAttempted, submitAttempted,
placeholder, placeholder,
error, error,
tabIndex, tabIndex,
spellcheck, spellcheck,
maxLength, maxLength,
showCounter = true, showCounter = true,
}: { }: {
label: string; label: string;
value?: string; value?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void; onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
validate?: (value: string) => string | undefined; validate?: (value: string) => string | undefined;
hidden?: boolean; hidden?: boolean;
submitAttempted?: boolean; submitAttempted?: boolean;
placeholder?: string; placeholder?: string;
error?: string; error?: string;
tabIndex?: number; tabIndex?: number;
spellcheck?: boolean; spellcheck?: boolean;
maxLength?: number; maxLength?: number;
showCounter?: boolean; showCounter?: boolean;
}) { }) {
const [internalTouched, setInternalTouched] = useState(false); const [internalTouched, setInternalTouched] = useState(false);
const isTouched = submitAttempted || internalTouched; const isTouched = submitAttempted || internalTouched;
const invalidMessage = useMemo(() => { const invalidMessage = useMemo(() => {
if (!isTouched && value === "") { if (!isTouched && value === "") {
return ""; return "";
} }
return validate?.(value) ?? ""; return validate?.(value) ?? "";
}, [isTouched, validate, value]); }, [isTouched, validate, value]);
return ( return (
<div className="flex flex-col gap-1 w-full"> <div className="flex flex-col gap-1 w-full">
<div className="flex items-end justify-between w-full"> <div className="flex items-end justify-between w-full">
<Label htmlFor={label} className="flex items-center text-sm"> <Label htmlFor={label} className="flex items-center text-sm">
{label} {label}
</Label> </Label>
</div> </div>
<Input <Input
id={label} id={label}
placeholder={placeholder ?? label} placeholder={placeholder ?? label}
value={value} value={value}
onChange={(e) => { onChange={(e) => {
onChange(e); onChange(e);
setInternalTouched(true); setInternalTouched(true);
}} }}
onBlur={() => setInternalTouched(true)} onBlur={() => setInternalTouched(true)}
name={label} name={label}
aria-invalid={error !== undefined || invalidMessage !== ""} aria-invalid={error !== undefined || invalidMessage !== ""}
type={hidden ? "password" : "text"} type={hidden ? "password" : "text"}
tabIndex={tabIndex} tabIndex={tabIndex}
spellCheck={spellcheck} spellCheck={spellcheck}
maxLength={maxLength} maxLength={maxLength}
showCounter={showCounter} showCounter={showCounter}
/> />
<div className="flex items-end justify-end w-full text-xs mb-0 -mt-1"> <div className="flex items-end justify-end w-full text-xs mb-0 -mt-1">
{error || invalidMessage !== "" ? ( {error || invalidMessage !== "" ? (
<Label className="text-destructive text-sm">{error ?? invalidMessage}</Label> <Label className="text-destructive text-sm">{error ?? invalidMessage}</Label>
) : ( ) : (
<Label className="opacity-0 text-sm">a</Label> <Label className="opacity-0 text-sm">a</Label>
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@@ -3,42 +3,40 @@ import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const iconButtonVariants = cva( 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]", "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: { variants: {
variant: { variant: {
default: "hover:text-foreground/70", default: "hover:text-foreground/70",
destructive: "text-destructive hover:text-destructive/70", destructive: "text-destructive hover:text-destructive/70",
yellow: "text-yellow-500 hover:text-yellow-500/70", yellow: "text-yellow-500 hover:text-yellow-500/70",
green: "text-green-500 hover:text-green-500/70", green: "text-green-500 hover:text-green-500/70",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
outline: "border bg-transparent dark:hover:bg-muted/40", outline: "border bg-transparent dark:hover:bg-muted/40",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
primary: "bg-primary text-primary-foreground hover:bg-primary/90", primary: "bg-primary text-primary-foreground hover:bg-primary/90",
}, },
size: { size: {
default: "w-6 h-6", default: "w-6 h-6",
sm: "w-5 h-5", sm: "w-5 h-5",
md: "w-9 h-9", md: "w-9 h-9",
lg: "w-10 h-10", lg: "w-10 h-10",
}, },
},
defaultVariants: {
variant: "default",
size: "default",
},
}, },
defaultVariants: {
variant: "default",
size: "default",
},
},
); );
function IconButton({ function IconButton({
className, className,
variant, variant,
size, size,
...props ...props
}: React.ComponentProps<"button"> & VariantProps<typeof iconButtonVariants>) { }: React.ComponentProps<"button"> & VariantProps<typeof iconButtonVariants>) {
return ( return <button type="button" className={cn(iconButtonVariants({ variant, size, className }))} {...props} />;
<button type="button" className={cn(iconButtonVariants({ variant, size, className }))} {...props} />
);
} }
export { IconButton, iconButtonVariants }; export { IconButton, iconButtonVariants };

View File

@@ -1,143 +1,143 @@
import { import {
Alert as PixelAlert, Alert as PixelAlert,
Check as PixelCheck, Check as PixelCheck,
ChevronDown as PixelChevronDown, ChevronDown as PixelChevronDown,
ChevronLeft as PixelChevronLeft, ChevronLeft as PixelChevronLeft,
ChevronRight as PixelChevronRight, ChevronRight as PixelChevronRight,
ChevronUp as PixelChevronUp, ChevronUp as PixelChevronUp,
Circle as PixelCircle, Circle as PixelCircle,
Clock as PixelClock, Clock as PixelClock,
Close as PixelClose, Close as PixelClose,
Edit as PixelEdit, Edit as PixelEdit,
Home as PixelHome, Home as PixelHome,
InfoBox as PixelInfo, InfoBox as PixelInfo,
Link as PixelLink, Link as PixelLink,
Loader as PixelLoader, Loader as PixelLoader,
Logout as PixelLogout, Logout as PixelLogout,
Moon as PixelMoon, Moon as PixelMoon,
MoreVertical as PixelMoreVertical, MoreVertical as PixelMoreVertical,
NoteDelete as PixelNoteDelete, NoteDelete as PixelNoteDelete,
Plus as PixelPlus, Plus as PixelPlus,
Server as PixelServer, Server as PixelServer,
Sun as PixelSun, Sun as PixelSun,
Trash as PixelTrash, Trash as PixelTrash,
Undo as PixelUndo, Undo as PixelUndo,
User as PixelUser, User as PixelUser,
ViewportWide as PixelViewportWide, ViewportWide as PixelViewportWide,
} from "@nsmr/pixelart-react"; } from "@nsmr/pixelart-react";
import { import {
CheckIcon as PhosphorCheck, CheckIcon as PhosphorCheck,
CheckCircleIcon as PhosphorCheckCircle, CheckCircleIcon as PhosphorCheckCircle,
CaretDownIcon as PhosphorChevronDown, CaretDownIcon as PhosphorChevronDown,
CaretLeftIcon as PhosphorChevronLeft, CaretLeftIcon as PhosphorChevronLeft,
CaretRightIcon as PhosphorChevronRight, CaretRightIcon as PhosphorChevronRight,
CaretUpIcon as PhosphorChevronUp, CaretUpIcon as PhosphorChevronUp,
CircleIcon as PhosphorCircle, CircleIcon as PhosphorCircle,
ClockIcon as PhosphorClock, ClockIcon as PhosphorClock,
DotsSixVerticalIcon as PhosphorDotsSixVertical, DotsSixVerticalIcon as PhosphorDotsSixVertical,
DotsThreeVerticalIcon as PhosphorDotsThreeVertical, DotsThreeVerticalIcon as PhosphorDotsThreeVertical,
PencilSimpleIcon as PhosphorEdit, PencilSimpleIcon as PhosphorEdit,
HashIcon as PhosphorHash, HashIcon as PhosphorHash,
HashStraightIcon as PhosphorHashStraight, HashStraightIcon as PhosphorHashStraight,
HouseIcon as PhosphorHome, HouseIcon as PhosphorHome,
InfoIcon as PhosphorInfo, InfoIcon as PhosphorInfo,
LinkIcon as PhosphorLink, LinkIcon as PhosphorLink,
SpinnerGapIcon as PhosphorLoader, SpinnerGapIcon as PhosphorLoader,
SignOutIcon as PhosphorLogOut, SignOutIcon as PhosphorLogOut,
MoonIcon as PhosphorMoon, MoonIcon as PhosphorMoon,
OctagonIcon as PhosphorOctagon, OctagonIcon as PhosphorOctagon,
PlusIcon as PhosphorPlus, PlusIcon as PhosphorPlus,
QuestionIcon as PhosphorQuestion, QuestionIcon as PhosphorQuestion,
HardDrivesIcon as PhosphorServer, HardDrivesIcon as PhosphorServer,
SunIcon as PhosphorSun, SunIcon as PhosphorSun,
TrashIcon as PhosphorTrash, TrashIcon as PhosphorTrash,
ArrowCounterClockwiseIcon as PhosphorUndo, ArrowCounterClockwiseIcon as PhosphorUndo,
UserIcon as PhosphorUser, UserIcon as PhosphorUser,
WarningIcon as PhosphorWarning, WarningIcon as PhosphorWarning,
XIcon as PhosphorX, XIcon as PhosphorX,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import type { IconStyle } from "@sprint/shared"; import type { IconStyle } from "@sprint/shared";
import { import {
AlertTriangle, AlertTriangle,
Check, Check,
CheckIcon, CheckIcon,
ChevronDown, ChevronDown,
ChevronDownIcon, ChevronDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ChevronUp, ChevronUp,
ChevronUpIcon, ChevronUpIcon,
CircleCheckIcon, CircleCheckIcon,
CircleIcon, CircleIcon,
CircleQuestionMark, CircleQuestionMark,
Edit, Edit,
EllipsisVertical, EllipsisVertical,
GripVerticalIcon, GripVerticalIcon,
Hash, Hash,
InfoIcon, InfoIcon,
Link, Link,
Loader, Loader,
Loader2Icon, Loader2Icon,
LogOut, LogOut,
Home as LucideHome, Home as LucideHome,
Moon, Moon,
OctagonXIcon, OctagonXIcon,
Plus, Plus,
ServerIcon, ServerIcon,
Sun, Sun,
Timer, Timer,
Trash, Trash,
TriangleAlertIcon, TriangleAlertIcon,
Undo, Undo,
Undo2, Undo2,
UserRound, UserRound,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useSessionSafe } from "@/components/session-provider"; import { useSessionSafe } from "@/components/session-provider";
const icons = { const icons = {
alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning }, alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning },
check: { lucide: Check, pixel: PixelCheck, phosphor: PhosphorCheck }, check: { lucide: Check, pixel: PixelCheck, phosphor: PhosphorCheck },
checkIcon: { lucide: CheckIcon, pixel: PixelCheck, phosphor: PhosphorCheck }, checkIcon: { lucide: CheckIcon, pixel: PixelCheck, phosphor: PhosphorCheck },
chevronDown: { lucide: ChevronDown, pixel: PixelChevronDown, phosphor: PhosphorChevronDown }, chevronDown: { lucide: ChevronDown, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
chevronDownIcon: { lucide: ChevronDownIcon, pixel: PixelChevronDown, phosphor: PhosphorChevronDown }, chevronDownIcon: { lucide: ChevronDownIcon, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
chevronLeftIcon: { lucide: ChevronLeftIcon, pixel: PixelChevronLeft, phosphor: PhosphorChevronLeft }, chevronLeftIcon: { lucide: ChevronLeftIcon, pixel: PixelChevronLeft, phosphor: PhosphorChevronLeft },
chevronRightIcon: { lucide: ChevronRightIcon, pixel: PixelChevronRight, phosphor: PhosphorChevronRight }, chevronRightIcon: { lucide: ChevronRightIcon, pixel: PixelChevronRight, phosphor: PhosphorChevronRight },
chevronUp: { lucide: ChevronUp, pixel: PixelChevronUp, phosphor: PhosphorChevronUp }, chevronUp: { lucide: ChevronUp, pixel: PixelChevronUp, phosphor: PhosphorChevronUp },
chevronUpIcon: { lucide: ChevronUpIcon, pixel: PixelChevronUp, phosphor: PhosphorChevronUp }, chevronUpIcon: { lucide: ChevronUpIcon, pixel: PixelChevronUp, phosphor: PhosphorChevronUp },
circleCheckIcon: { lucide: CircleCheckIcon, pixel: PixelCheck, phosphor: PhosphorCheckCircle }, circleCheckIcon: { lucide: CircleCheckIcon, pixel: PixelCheck, phosphor: PhosphorCheckCircle },
circleIcon: { lucide: CircleIcon, pixel: PixelCircle, phosphor: PhosphorCircle }, circleIcon: { lucide: CircleIcon, pixel: PixelCircle, phosphor: PhosphorCircle },
circleQuestionMark: { lucide: CircleQuestionMark, pixel: PixelNoteDelete, phosphor: PhosphorQuestion }, circleQuestionMark: { lucide: CircleQuestionMark, pixel: PixelNoteDelete, phosphor: PhosphorQuestion },
edit: { lucide: Edit, pixel: PixelEdit, phosphor: PhosphorEdit }, edit: { lucide: Edit, pixel: PixelEdit, phosphor: PhosphorEdit },
ellipsisVertical: { ellipsisVertical: {
lucide: EllipsisVertical, lucide: EllipsisVertical,
pixel: PixelMoreVertical, pixel: PixelMoreVertical,
phosphor: PhosphorDotsThreeVertical, phosphor: PhosphorDotsThreeVertical,
}, },
gripVerticalIcon: { gripVerticalIcon: {
lucide: GripVerticalIcon, lucide: GripVerticalIcon,
pixel: PixelViewportWide, pixel: PixelViewportWide,
phosphor: PhosphorDotsSixVertical, phosphor: PhosphorDotsSixVertical,
}, },
hash: { lucide: Hash, pixel: PhosphorHashStraight, phosphor: PhosphorHash }, hash: { lucide: Hash, pixel: PhosphorHashStraight, phosphor: PhosphorHash },
home: { lucide: LucideHome, pixel: PixelHome, phosphor: PhosphorHome }, home: { lucide: LucideHome, pixel: PixelHome, phosphor: PhosphorHome },
infoIcon: { lucide: InfoIcon, pixel: PixelInfo, phosphor: PhosphorInfo }, infoIcon: { lucide: InfoIcon, pixel: PixelInfo, phosphor: PhosphorInfo },
link: { lucide: Link, pixel: PixelLink, phosphor: PhosphorLink }, link: { lucide: Link, pixel: PixelLink, phosphor: PhosphorLink },
loader: { lucide: Loader, pixel: PixelLoader, phosphor: PhosphorLoader }, loader: { lucide: Loader, pixel: PixelLoader, phosphor: PhosphorLoader },
loader2Icon: { lucide: Loader2Icon, pixel: PixelLoader, phosphor: PhosphorLoader }, loader2Icon: { lucide: Loader2Icon, pixel: PixelLoader, phosphor: PhosphorLoader },
logOut: { lucide: LogOut, pixel: PixelLogout, phosphor: PhosphorLogOut }, logOut: { lucide: LogOut, pixel: PixelLogout, phosphor: PhosphorLogOut },
moon: { lucide: Moon, pixel: PixelMoon, phosphor: PhosphorMoon }, moon: { lucide: Moon, pixel: PixelMoon, phosphor: PhosphorMoon },
octagonXIcon: { lucide: OctagonXIcon, pixel: PixelClose, phosphor: PhosphorOctagon }, octagonXIcon: { lucide: OctagonXIcon, pixel: PixelClose, phosphor: PhosphorOctagon },
plus: { lucide: Plus, pixel: PixelPlus, phosphor: PhosphorPlus }, plus: { lucide: Plus, pixel: PixelPlus, phosphor: PhosphorPlus },
serverIcon: { lucide: ServerIcon, pixel: PixelServer, phosphor: PhosphorServer }, serverIcon: { lucide: ServerIcon, pixel: PixelServer, phosphor: PhosphorServer },
sun: { lucide: Sun, pixel: PixelSun, phosphor: PhosphorSun }, sun: { lucide: Sun, pixel: PixelSun, phosphor: PhosphorSun },
timer: { lucide: Timer, pixel: PixelClock, phosphor: PhosphorClock }, timer: { lucide: Timer, pixel: PixelClock, phosphor: PhosphorClock },
trash: { lucide: Trash, pixel: PixelTrash, phosphor: PhosphorTrash }, trash: { lucide: Trash, pixel: PixelTrash, phosphor: PhosphorTrash },
triangleAlertIcon: { lucide: TriangleAlertIcon, pixel: PixelAlert, phosphor: PhosphorWarning }, triangleAlertIcon: { lucide: TriangleAlertIcon, pixel: PixelAlert, phosphor: PhosphorWarning },
undo: { lucide: Undo, pixel: PixelUndo, phosphor: PhosphorUndo }, undo: { lucide: Undo, pixel: PixelUndo, phosphor: PhosphorUndo },
undo2: { lucide: Undo2, pixel: PixelUndo, phosphor: PhosphorUndo }, undo2: { lucide: Undo2, pixel: PixelUndo, phosphor: PhosphorUndo },
userRound: { lucide: UserRound, pixel: PixelUser, phosphor: PhosphorUser }, userRound: { lucide: UserRound, pixel: PixelUser, phosphor: PhosphorUser },
x: { lucide: X, pixel: PixelClose, phosphor: PhosphorX }, x: { lucide: X, pixel: PixelClose, phosphor: PhosphorX },
}; };
export type IconName = keyof typeof icons; export type IconName = keyof typeof icons;
@@ -146,41 +146,41 @@ export const iconStyles = ["lucide", "pixel", "phosphor"] as const;
export type { IconStyle }; export type { IconStyle };
export default function Icon({ export default function Icon({
icon, icon,
iconStyle, iconStyle,
size = 24, size = 24,
...props ...props
}: { }: {
icon: IconName; icon: IconName;
iconStyle?: IconStyle; iconStyle?: IconStyle;
size?: number | string; size?: number | string;
color?: string; color?: string;
} & React.ComponentProps<"svg">) { } & React.ComponentProps<"svg">) {
const session = useSessionSafe(); const session = useSessionSafe();
const resolvedStyle = (iconStyle ?? const resolvedStyle = (iconStyle ??
session?.user?.iconPreference ?? session?.user?.iconPreference ??
localStorage.getItem("iconPreference") ?? localStorage.getItem("iconPreference") ??
"lucide") as IconStyle; "lucide") as IconStyle;
const IconComponent = icons[icon]?.[resolvedStyle]; const IconComponent = icons[icon]?.[resolvedStyle];
if (localStorage.getItem("iconPreference") !== resolvedStyle) if (localStorage.getItem("iconPreference") !== resolvedStyle)
localStorage.setItem("iconPreference", resolvedStyle); localStorage.setItem("iconPreference", resolvedStyle);
if (!IconComponent) { if (!IconComponent) {
return null; return null;
} }
return ( return (
<IconComponent <IconComponent
size={size} size={size}
fill={ fill={
(resolvedStyle === "pixel" && icon === "moon") || (resolvedStyle === "pixel" && icon === "moon") ||
(resolvedStyle === "pixel" && icon === "hash") || (resolvedStyle === "pixel" && icon === "hash") ||
resolvedStyle === "phosphor" resolvedStyle === "phosphor"
? "var(--foreground)" ? "var(--foreground)"
: "transparent" : "transparent"
} }
{...props} {...props}
/> />
); );
} }

View File

@@ -3,65 +3,65 @@ import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Input({ function Input({
className, className,
type, type,
showCounter = true, showCounter = true,
showHashPrefix = false, showHashPrefix = false,
inputClassName, inputClassName,
...props ...props
}: React.ComponentProps<"input"> & { }: React.ComponentProps<"input"> & {
showCounter?: boolean; showCounter?: boolean;
showHashPrefix?: boolean; showHashPrefix?: boolean;
inputClassName?: string; inputClassName?: string;
}) { }) {
const maxLength = typeof props.maxLength === "number" ? props.maxLength : undefined; const maxLength = typeof props.maxLength === "number" ? props.maxLength : undefined;
const currentLength = typeof props.value === "string" ? props.value.length : undefined; const currentLength = typeof props.value === "string" ? props.value.length : undefined;
return ( return (
<div <div
className={cn( className={cn(
"border-input dark:bg-input/30 flex h-9 w-full min-w-0 items-center border bg-transparent", "border-input dark:bg-input/30 flex h-9 w-full min-w-0 items-center border bg-transparent",
"transition-[color,box-shadow]", "transition-[color,box-shadow]",
"has-[:focus-visible]:border-ring", "has-[:focus-visible]:border-ring",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"aria-invalid:border-destructive", "aria-invalid:border-destructive",
className, 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 && ( {String(currentLength).padStart(String(maxLength).length, "0")}/{maxLength}
<span className="border-r px-1 py-1 text-muted-foreground"> </span>
<Icon icon="hash" className="size-3.5" /> )}
</span> </div>
)} );
<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>
);
} }
export { Input }; export { Input };

View File

@@ -4,16 +4,16 @@ import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) { function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( 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", "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, className,
)} )}
{...props} {...props}
/> />
); );
} }
export { Label }; export { Label };

View File

@@ -4,39 +4,39 @@ import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) { 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>) { 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({ function PopoverContent({
className, className,
align = "center", align = "center",
sideOffset = 4, sideOffset = 4,
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) { }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return ( return (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
<PopoverPrimitive.Content <PopoverPrimitive.Content
data-slot="popover-content" data-slot="popover-content"
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground z-50 w-72", "bg-popover text-popover-foreground z-50 w-72",
"origin-(--radix-popover-content-transform-origin) border p-4", "origin-(--radix-popover-content-transform-origin) border p-4",
"shadow-md outline-hidden", "shadow-md outline-hidden",
className, className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
); );
} }
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { 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 }; export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -7,53 +7,53 @@ import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function ResizablePanelGroup({ className, ...props }: React.ComponentProps<typeof Group>) { function ResizablePanelGroup({ className, ...props }: React.ComponentProps<typeof Group>) {
return ( return (
<Group <Group
data-slot="resizable-panel-group" data-slot="resizable-panel-group"
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)} className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props} {...props}
/> />
); );
} }
function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) { function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
return <Panel data-slot="resizable-panel" {...props} />; return <Panel data-slot="resizable-panel" {...props} />;
} }
function ResizableSeparator({ function ResizableSeparator({
withHandle, withHandle,
className, className,
...props ...props
}: React.ComponentProps<typeof Separator> & { }: React.ComponentProps<typeof Separator> & {
withHandle?: boolean; withHandle?: boolean;
}) { }) {
return ( return (
<Separator <Separator
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"relative flex w-1 items-center justify-center", "relative flex w-1 items-center justify-center",
"after:absolute after:inset-y-0 after:left-1/2", "after:absolute after:inset-y-0 after:left-1/2",
"after:w-1 after:-translate-x-1/2 focus-visible:ring-0", "after:w-1 after:-translate-x-1/2 focus-visible:ring-0",
"focus-visible:ring-offset-0 focus-visible:outline-hidden", "focus-visible:ring-offset-0 focus-visible:outline-hidden",
"data-[panel-group-direction=vertical]:h-px", "data-[panel-group-direction=vertical]:h-px",
"data-[panel-group-direction=vertical]:w-full", "data-[panel-group-direction=vertical]:w-full",
"data-[panel-group-direction=vertical]:after:left-0", "data-[panel-group-direction=vertical]:after:left-0",
"data-[panel-group-direction=vertical]:after:h-1", "data-[panel-group-direction=vertical]:after:h-1",
"data-[panel-group-direction=vertical]:after:w-full", "data-[panel-group-direction=vertical]:after:w-full",
"data-[panel-group-direction=vertical]:after:translate-x-0", "data-[panel-group-direction=vertical]:after:translate-x-0",
"data-[panel-group-direction=vertical]:after:-translate-y-1/2", "data-[panel-group-direction=vertical]:after:-translate-y-1/2",
"[&[data-panel-group-direction=vertical]>div]:rotate-90", "[&[data-panel-group-direction=vertical]>div]:rotate-90",
className, className,
)} )}
{...props} {...props}
> >
{withHandle && ( {withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border"> <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" /> <Icon icon="gripVerticalIcon" className="size-2.5" />
</div> </div>
)} )}
</Separator> </Separator>
); );
} }
export { ResizablePanelGroup, ResizablePanel, ResizableSeparator }; export { ResizablePanelGroup, ResizablePanel, ResizableSeparator };

View File

@@ -5,228 +5,228 @@ import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { 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>) { 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>) { 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({ function SelectTrigger({
className, className,
size = "default", size = "default",
variant = "default", variant = "default",
children, children,
isOpen, isOpen,
label, label,
hasValue, hasValue,
labelPosition = "top", labelPosition = "top",
chevronClassName, chevronClassName,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
isOpen?: boolean; isOpen?: boolean;
size?: "sm" | "default"; size?: "sm" | "default";
variant?: "default" | "unstyled"; variant?: "default" | "unstyled";
label?: string; label?: string;
hasValue?: boolean; hasValue?: boolean;
labelPosition?: "top" | "bottom"; labelPosition?: "top" | "bottom";
chevronClassName?: string; chevronClassName?: string;
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
variant === "unstyled" variant === "unstyled"
? "cursor-pointer bg-transparent shadow-none outline-none" ? "cursor-pointer bg-transparent shadow-none outline-none"
: [ : [
"cursor-pointer border data-[placeholder]:text-muted-foreground", "cursor-pointer border data-[placeholder]:text-muted-foreground",
"[&_svg:not([class*='text-'])]:text-muted-foreground", "[&_svg:not([class*='text-'])]:text-muted-foreground",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
"aria-invalid:border-destructive dark:hover:bg-muted/40", "aria-invalid:border-destructive dark:hover:bg-muted/40",
"relative flex w-fit items-center justify-between gap-2 border", "relative flex w-fit items-center justify-between gap-2 border",
"bg-transparent px-3 py-2 text-sm whitespace-nowrap", "bg-transparent px-3 py-2 text-sm whitespace-nowrap",
"shadow-xs outline-none disabled:cursor-not-allowed", "shadow-xs outline-none disabled:cursor-not-allowed",
"disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8", "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]:line-clamp-1 *:data-[slot=select-value]:flex",
"*:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2", "*: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", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
], ],
className, className,
)} )}
{...props} {...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 && ( {label}
<span </span>
className={cn( )}
"text-muted-foreground bg-background absolute left-1 text-[12px] leading-none font-700", {children}
labelPosition === "top" ? "-top-1" : "-bottom-1", <SelectPrimitive.Icon asChild>
)} <Icon
> icon="chevronDownIcon"
{label} className={cn("size-4.5 opacity-50", chevronClassName)}
</span> style={{ rotate: isOpen ? "180deg" : "0deg" }}
)} />
{children} </SelectPrimitive.Icon>
<SelectPrimitive.Icon asChild> </SelectPrimitive.Trigger>
<Icon );
icon="chevronDownIcon"
className={cn("size-4.5 opacity-50", chevronClassName)}
style={{ rotate: isOpen ? "180deg" : "0deg" }}
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
} }
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "item-aligned", position = "item-aligned",
align = "center", align = "center",
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
data-align={align} data-align={align}
className={cn( className={cn(
"bg-popover text-popover-foreground", "bg-popover text-popover-foreground",
"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95", "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-[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=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", "data-[side=top]:slide-in-from-bottom-2 relative z-50",
"max-h-(--radix-select-content-available-height) min-w-[8rem]", "max-h-(--radix-select-content-available-height) min-w-[8rem]",
"origin-(--radix-select-content-transform-origin) overflow-x-hidden", "origin-(--radix-select-content-transform-origin) overflow-x-hidden",
"overflow-y-auto border shadow-md", "overflow-y-auto border shadow-md",
position === "popper" && 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", "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", position === "popper" && align === "start" && "data-[side=bottom]:-translate-x-0",
className, className,
)} )}
position={position} position={position}
align={align} align={align}
{...props} {...props}
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1", "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)} )}
> >
{children} {children}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
); );
} }
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs font-700", className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs font-700", className)}
{...props} {...props}
/> />
); );
} }
function SelectItem({ function SelectItem({
className, className,
textClassName, textClassName,
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Item> & { }: React.ComponentProps<typeof SelectPrimitive.Item> & {
textClassName?: string; textClassName?: string;
}) { }) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent/40 focus:text-accent-foreground", "focus:bg-accent/40 focus:text-accent-foreground",
"[&_svg:not([class*='text-'])]:text-muted-foreground relative", "[&_svg:not([class*='text-'])]:text-muted-foreground relative",
"flex w-full cursor-pointer items-center gap-2 py-1.5 pr-8 pl-2", "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", "text-sm outline-hidden select-none data-[disabled]:pointer-events-none",
"data-[disabled]:opacity-50 [&_svg]:pointer-events-none", "data-[disabled]:opacity-50 [&_svg]:pointer-events-none",
"[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className, className,
)} )}
{...props} {...props}
> >
<span <span
data-slot="select-item-indicator" data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center" className="absolute right-2 flex size-3.5 items-center justify-center"
> >
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<Icon icon="checkIcon" className="size-4" /> <Icon icon="checkIcon" className="size-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
{textClassName ? ( {textClassName ? (
<span className={cn(textClassName)}>{children}</span> <span className={cn(textClassName)}>{children}</span>
) : ( ) : (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
)} )}
</SelectPrimitive.Item> </SelectPrimitive.Item>
); );
} }
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) { function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
); );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)} className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props} {...props}
> >
<Icon icon="chevronUpIcon" className="size-4" /> <Icon icon="chevronUpIcon" className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
); );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)} className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props} {...props}
> >
<Icon icon="chevronDownIcon" className="size-4" /> <Icon icon="chevronDownIcon" className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
); );
} }
export { export {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectScrollDownButton, SelectScrollDownButton,
SelectScrollUpButton, SelectScrollUpButton,
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
}; };

View File

@@ -3,30 +3,30 @@ import { Toaster as Sonner, type ToasterProps } from "sonner";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme(); const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
icons={{ icons={{
success: <Icon icon="circleCheckIcon" className="size-4" />, success: <Icon icon="circleCheckIcon" className="size-4" />,
info: <Icon icon="infoIcon" className="size-4" />, info: <Icon icon="infoIcon" className="size-4" />,
warning: <Icon icon="triangleAlertIcon" className="size-4" />, warning: <Icon icon="triangleAlertIcon" className="size-4" />,
error: <Icon icon="octagonXIcon" className="size-4" />, error: <Icon icon="octagonXIcon" className="size-4" />,
loading: <Icon icon="loader2Icon" className="size-4 animate-spin" />, loading: <Icon icon="loader2Icon" className="size-4 animate-spin" />,
}} }}
style={ style={
{ {
"--normal-bg": "var(--popover)", "--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)", "--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)", "--normal-border": "var(--border)",
"--border-radius": "0", "--border-radius": "0",
} as React.CSSProperties } as React.CSSProperties
} }
{...props} {...props}
/> />
); );
}; };
export { Toaster }; export { Toaster };

View File

@@ -2,15 +2,15 @@ import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) { function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return ( return (
<Icon <Icon
icon="loader" icon="loader"
role="status" role="status"
aria-label="Loading" aria-label="Loading"
className={cn("size-4 animate-spin", className)} className={cn("size-4 animate-spin", className)}
{...props} {...props}
/> />
); );
} }
export { Spinner }; export { Spinner };

View File

@@ -3,86 +3,84 @@ import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
<div data-slot="table-container" className="relative w-full overflow-hidden"> <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} /> <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div> </div>
); );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 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">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return ( return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
<tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />
);
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return ( return (
<tfoot <tfoot
data-slot="table-footer" data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)} className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props} {...props}
/> />
); );
} }
function TableRow({ function TableRow({
className, className,
hoverEffect = true, hoverEffect = true,
...props ...props
}: React.ComponentProps<"tr"> & { hoverEffect?: boolean }) { }: React.ComponentProps<"tr"> & { hoverEffect?: boolean }) {
return ( return (
<tr <tr
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"data-[state=selected]:bg-muted h-[25px] border-b", "data-[state=selected]:bg-muted h-[25px] border-b",
hoverEffect && "hover:bg-muted/40", hoverEffect && "hover:bg-muted/40",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return ( return (
<th <th
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground px-2 h-[25px] text-left text-sm align-middle font-medium", "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]", "whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return ( return (
<td <td
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"px-2 py-1 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "px-2 py-1 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return ( return (
<caption <caption
data-slot="table-caption" data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props} {...props}
/> />
); );
} }
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; 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"; import { cn } from "@/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) { function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return ( return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
<TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />
);
} }
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) { function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return ( return (
<TabsPrimitive.List <TabsPrimitive.List
data-slot="tabs-list" data-slot="tabs-list"
className={cn( className={cn(
"border text-muted-foreground inline-flex h-9 w-fit items-center justify-center p-[3px]", "border text-muted-foreground inline-flex h-9 w-fit items-center justify-center p-[3px]",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) { function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return ( return (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground", "data-[state=active]:bg-background dark:data-[state=active]:text-foreground",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring", "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", "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)]", "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", "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]", "text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
"focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none", "focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none",
"disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]: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", "[&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) { function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Content
data-slot="tabs-content" data-slot="tabs-content"
className={cn("flex-1 outline-none", className)} className={cn("flex-1 outline-none", className)}
{...props} {...props}
/> />
); );
} }
export { Tabs, TabsList, TabsTrigger, TabsContent }; export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -3,23 +3,23 @@ import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return ( return (
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"border-input dark:bg-input/30 w-full min-w-0 border bg-transparent", "border-input dark:bg-input/30 w-full min-w-0 border bg-transparent",
"transition-[color,box-shadow]", "transition-[color,box-shadow]",
"focus-visible:border-ring", "focus-visible:border-ring",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "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", "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", "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"outline-none", "outline-none",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
export { Textarea }; export { Textarea };

View File

@@ -9,89 +9,84 @@ import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function UploadAvatar({ export function UploadAvatar({
name, name,
username, username,
avatarURL, avatarURL,
onAvatarUploaded, onAvatarUploaded,
className, className,
}: { }: {
name?: string; name?: string;
username?: string; username?: string;
avatarURL?: string | null; avatarURL?: string | null;
onAvatarUploaded: (avatarURL: string) => void; onAvatarUploaded: (avatarURL: string) => void;
label?: string; label?: string;
className?: string; className?: string;
}) { }) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const uploadAvatar = useUploadAvatar(); const uploadAvatar = useUploadAvatar();
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
setError(null); setError(null);
try { try {
const url = await uploadAvatar.mutateAsync(file); const url = await uploadAvatar.mutateAsync(file);
onAvatarUploaded(url); onAvatarUploaded(url);
setUploading(false); setUploading(false);
toast.success( toast.success(<div className="flex flex-col items-center gap-4">Avatar uploaded successfully</div>, {
<div className="flex flex-col items-center gap-4"> dismissible: false,
Avatar uploaded successfully });
</div>, } catch (err) {
{ const message = parseError(err as Error);
dismissible: false, setError(message);
}, setUploading(false);
);
} catch (err) {
const message = parseError(err as Error);
setError(message);
setUploading(false);
toast.error(`Error uploading avatar: ${message}`, { toast.error(`Error uploading avatar: ${message}`, {
dismissible: false, dismissible: false,
}); });
} }
}; };
return ( return (
<div className={cn("flex flex-col items-center gap-4", className)}> <div className={cn("flex flex-col items-center gap-4", className)}>
<Button <Button
variant="dummy" variant="dummy"
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
onMouseOver={() => setShowEdit(true)} onMouseOver={() => setShowEdit(true)}
onMouseOut={() => setShowEdit(false)} onMouseOut={() => setShowEdit(false)}
className="w-24 h-24 rounded-full border-1 p-0 relative overflow-hidden" className="w-24 h-24 rounded-full border-1 p-0 relative overflow-hidden"
> >
<Avatar <Avatar
name={name} name={name}
username={username} username={username}
avatarURL={avatarURL} avatarURL={avatarURL}
size={24} size={24}
textClass={"text-4xl"} textClass={"text-4xl"}
strong strong
/> />
{!uploading && showEdit && ( {!uploading && showEdit && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40"> <div className="absolute inset-0 flex items-center justify-center bg-black/40">
<Icon icon="edit" className="size-6 text-white drop-shadow-md" /> <Icon icon="edit" className="size-6 text-white drop-shadow-md" />
</div> </div>
)} )}
</Button> </Button>
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
accept="image/png,image/jpeg,image/webp,image/gif" accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden" className="hidden"
/> />
{error && <Label className="text-destructive text-sm">{error}</Label>} {error && <Label className="text-destructive text-sm">{error}</Label>}
</div> </div>
); );
} }

View File

@@ -9,83 +9,81 @@ import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function UploadOrgIcon({ export function UploadOrgIcon({
name, name,
slug, slug,
iconURL, iconURL,
organisationId, organisationId,
onIconUploaded, onIconUploaded,
className, className,
}: { }: {
name: string; name: string;
slug: string; slug: string;
iconURL?: string | null; iconURL?: string | null;
organisationId: number; organisationId: number;
onIconUploaded: (iconURL: string) => void; onIconUploaded: (iconURL: string) => void;
className?: string; className?: string;
}) { }) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const uploadIcon = useUploadOrganisationIcon(); const uploadIcon = useUploadOrganisationIcon();
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
setError(null); setError(null);
try { try {
const url = await uploadIcon.mutateAsync({ file, organisationId }); const url = await uploadIcon.mutateAsync({ file, organisationId });
onIconUploaded(url); onIconUploaded(url);
setUploading(false); setUploading(false);
toast.success( toast.success(
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">Organisation icon uploaded successfully</div>,
Organisation icon uploaded successfully {
</div>, dismissible: false,
{ },
dismissible: false, );
}, } catch (err) {
); const message = parseError(err as Error);
} catch (err) { setError(message);
const message = parseError(err as Error); setUploading(false);
setError(message);
setUploading(false);
toast.error(`Error uploading icon: ${message}`, { toast.error(`Error uploading icon: ${message}`, {
dismissible: false, dismissible: false,
}); });
} }
}; };
return ( return (
<div className={cn("flex flex-col items-center gap-4", className)}> <div className={cn("flex flex-col items-center gap-4", className)}>
<Button <Button
variant="dummy" variant="dummy"
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
onMouseOver={() => setShowEdit(true)} onMouseOver={() => setShowEdit(true)}
onMouseOut={() => setShowEdit(false)} onMouseOut={() => setShowEdit(false)}
className="size-24 rounded-lg border-1 p-0 relative overflow-hidden" className="size-24 rounded-lg border-1 p-0 relative overflow-hidden"
> >
<OrgIcon name={name} slug={slug} iconURL={iconURL} size={24} textClass="text-4xl" /> <OrgIcon name={name} slug={slug} iconURL={iconURL} size={24} textClass="text-4xl" />
{!uploading && showEdit && ( {!uploading && showEdit && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40"> <div className="absolute inset-0 flex items-center justify-center bg-black/40">
<Icon icon="edit" className="size-6 text-white drop-shadow-md" /> <Icon icon="edit" className="size-6 text-white drop-shadow-md" />
</div> </div>
)} )}
</Button> </Button>
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
accept="image/png,image/jpeg,image/webp,image/gif" accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden" className="hidden"
/> />
{error && <Label className="text-destructive text-sm">{error}</Label>} {error && <Label className="text-destructive text-sm">{error}</Label>}
</div> </div>
); );
} }

View File

@@ -4,56 +4,56 @@ import SmallUserDisplay from "@/components/small-user-display";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function UserSelect({ export function UserSelect({
users, users,
value, value,
onChange, onChange,
fallbackUser, fallbackUser,
placeholder = "Select user", placeholder = "Select user",
}: { }: {
users: UserRecord[]; users: UserRecord[];
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
fallbackUser?: UserRecord | null; fallbackUser?: UserRecord | null;
placeholder?: string; placeholder?: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const renderSelectedValue = () => { const renderSelectedValue = () => {
if (value === "unassigned") { if (value === "unassigned") {
return "Unassigned"; return "Unassigned";
} }
const user = users.find((u) => u.id.toString() === value); const user = users.find((u) => u.id.toString() === value);
const className = "p-0 py-2 text-sm"; const className = "p-0 py-2 text-sm";
if (user) { if (user) {
return <SmallUserDisplay user={user} className={className} />; return <SmallUserDisplay user={user} className={className} />;
} }
if (fallbackUser) { if (fallbackUser) {
return <SmallUserDisplay user={fallbackUser} className={className} />; return <SmallUserDisplay user={fallbackUser} className={className} />;
} }
return null; return null;
}; };
return ( return (
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}> <Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
<SelectTrigger className="w-fit p-0 px-2 py-2" isOpen={isOpen}> <SelectTrigger className="w-fit p-0 px-2 py-2" isOpen={isOpen}>
<SelectValue placeholder={placeholder}>{renderSelectedValue()}</SelectValue> <SelectValue placeholder={placeholder}>{renderSelectedValue()}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
side="bottom" side="bottom"
position="popper" position="popper"
className="data-[side=bottom]:translate-y-1 data-[side=bottom]:translate-x-1" className="data-[side=bottom]:translate-y-1 data-[side=bottom]:translate-x-1"
> >
<SelectItem value="unassigned">Unassigned</SelectItem> <SelectItem value="unassigned">Unassigned</SelectItem>
{users.map((user) => ( {users.map((user) => (
<SelectItem key={user.id} value={user.id.toString()}> <SelectItem key={user.id} value={user.id.toString()}>
<SmallUserDisplay user={user} className="p-0" /> <SmallUserDisplay user={user} className="p-0" />
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
); );
} }

View File

@@ -1,65 +1,65 @@
@font-face { @font-face {
font-family: "Commit Mono"; font-family: "Commit Mono";
src: url("/fonts/CommitMono-Variable.woff2") format("woff2"); src: url("/fonts/CommitMono-Variable.woff2") format("woff2");
font-weight: 200 700; font-weight: 200 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Commit Mono"; font-family: "Commit Mono";
src: url("/fonts/CommitMono-Variable.woff2") format("woff2"); src: url("/fonts/CommitMono-Variable.woff2") format("woff2");
font-weight: 200 700; font-weight: 200 700;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Basteleur"; font-family: "Basteleur";
src: src:
url("/fonts/Basteleur-Bold.woff2") format("woff2"), url("/fonts/Basteleur-Bold.woff2") format("woff2"),
url("/fonts/Basteleur-Bold.woff") format("woff"); url("/fonts/Basteleur-Bold.woff") format("woff");
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Basteleur"; font-family: "Basteleur";
src: src:
url("/fonts/Basteleur-Moonlight.woff2") format("woff2"), url("/fonts/Basteleur-Moonlight.woff2") format("woff2"),
url("/fonts/Basteleur-Moonlight.woff") format("woff"); url("/fonts/Basteleur-Moonlight.woff") format("woff");
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Sorts Mill Goudy"; font-family: "Sorts Mill Goudy";
src: url("/fonts/SortsMillGoudy-Regular.woff") format("woff"); src: url("/fonts/SortsMillGoudy-Regular.woff") format("woff");
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Sorts Mill Goudy"; font-family: "Sorts Mill Goudy";
src: url("/fonts/SortsMillGoudy-Italic.woff") format("woff"); src: url("/fonts/SortsMillGoudy-Italic.woff") format("woff");
font-weight: 400; font-weight: 400;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
} }
.font-mono { .font-mono {
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
} }
.font-basteleur { .font-basteleur {
font-family: "Basteleur", serif; font-family: "Basteleur", serif;
line-height: 1; line-height: 1;
transform: translateY(0.1em); transform: translateY(0.1em);
} }
.font-goudy { .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"; import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 5 * 60 * 1000, // 5 mins staleTime: 5 * 60 * 1000, // 5 mins
gcTime: 10 * 60 * 1000, // 10 mins gcTime: 10 * 60 * 1000, // 10 mins
retry: 1, retry: 1,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchOnReconnect: true, refetchOnReconnect: true,
},
mutations: {
retry: 0,
},
}, },
mutations: {
retry: 0,
},
},
}); });

View File

@@ -5,31 +5,31 @@ import { useOrganisations } from "@/lib/query/hooks/organisations";
import { useProjects } from "@/lib/query/hooks/projects"; import { useProjects } from "@/lib/query/hooks/projects";
export function useSelectedOrganisation() { export function useSelectedOrganisation() {
const { selectedOrganisationId } = useSelection(); const { selectedOrganisationId } = useSelection();
const { data: organisations = [] } = useOrganisations(); const { data: organisations = [] } = useOrganisations();
return useMemo( return useMemo(
() => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null, () => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
[organisations, selectedOrganisationId], [organisations, selectedOrganisationId],
); );
} }
export function useSelectedProject() { export function useSelectedProject() {
const { selectedOrganisationId, selectedProjectId } = useSelection(); const { selectedOrganisationId, selectedProjectId } = useSelection();
const { data: projects = [] } = useProjects(selectedOrganisationId); const { data: projects = [] } = useProjects(selectedOrganisationId);
return useMemo( return useMemo(
() => projects.find((project) => project.Project.id === selectedProjectId) ?? null, () => projects.find((project) => project.Project.id === selectedProjectId) ?? null,
[projects, selectedProjectId], [projects, selectedProjectId],
); );
} }
export function useSelectedIssue() { export function useSelectedIssue() {
const { selectedProjectId, selectedIssueId } = useSelection(); const { selectedProjectId, selectedIssueId } = useSelection();
const { data: issues = [] } = useIssues(selectedProjectId); const { data: issues = [] } = useIssues(selectedProjectId);
return useMemo( return useMemo(
() => issues.find((issue) => issue.Issue.id === selectedIssueId) ?? null, () => issues.find((issue) => issue.Issue.id === selectedIssueId) ?? null,
[issues, selectedIssueId], [issues, selectedIssueId],
); );
} }

View File

@@ -1,78 +1,78 @@
import type { import type {
IssueCreateRequest, IssueCreateRequest,
IssueRecord, IssueRecord,
IssueResponse, IssueResponse,
IssuesReplaceStatusRequest, IssuesReplaceStatusRequest,
IssueUpdateRequest, IssueUpdateRequest,
StatusCountResponse, StatusCountResponse,
SuccessResponse, SuccessResponse,
} from "@sprint/shared"; } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { issue } from "@/lib/server"; import { issue } from "@/lib/server";
export function useIssues(projectId?: number | null) { export function useIssues(projectId?: number | null) {
return useQuery<IssueResponse[]>({ return useQuery<IssueResponse[]>({
queryKey: queryKeys.issues.byProject(projectId ?? 0), queryKey: queryKeys.issues.byProject(projectId ?? 0),
queryFn: () => issue.byProject(projectId ?? 0), queryFn: () => issue.byProject(projectId ?? 0),
enabled: Boolean(projectId), enabled: Boolean(projectId),
}); });
} }
export function useIssueStatusCount(organisationId?: number | null, status?: string | null) { export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
return useQuery<StatusCountResponse>({ return useQuery<StatusCountResponse>({
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""), queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""),
queryFn: () => issue.statusCount(organisationId ?? 0, status ?? ""), queryFn: () => issue.statusCount(organisationId ?? 0, status ?? ""),
enabled: Boolean(organisationId && status), enabled: Boolean(organisationId && status),
}); });
} }
export function useCreateIssue() { export function useCreateIssue() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<IssueRecord, Error, IssueCreateRequest>({ return useMutation<IssueRecord, Error, IssueCreateRequest>({
mutationKey: ["issues", "create"], mutationKey: ["issues", "create"],
mutationFn: issue.create, mutationFn: issue.create,
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.issues.byProject(variables.projectId), queryKey: queryKeys.issues.byProject(variables.projectId),
}); });
}, },
}); });
} }
export function useUpdateIssue() { export function useUpdateIssue() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<IssueRecord, Error, IssueUpdateRequest>({ return useMutation<IssueRecord, Error, IssueUpdateRequest>({
mutationKey: ["issues", "update"], mutationKey: ["issues", "update"],
mutationFn: issue.update, mutationFn: issue.update,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
}, },
}); });
} }
export function useDeleteIssue() { export function useDeleteIssue() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<SuccessResponse, Error, number>({ return useMutation<SuccessResponse, Error, number>({
mutationKey: ["issues", "delete"], mutationKey: ["issues", "delete"],
mutationFn: issue.delete, mutationFn: issue.delete,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
}, },
}); });
} }
export function useReplaceIssueStatus() { export function useReplaceIssueStatus() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<unknown, Error, IssuesReplaceStatusRequest>({ return useMutation<unknown, Error, IssuesReplaceStatusRequest>({
mutationKey: ["issues", "replace-status"], mutationKey: ["issues", "replace-status"],
mutationFn: issue.replaceStatus, mutationFn: issue.replaceStatus,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
}, },
}); });
} }

View File

@@ -1,113 +1,113 @@
import type { import type {
OrgAddMemberRequest, OrgAddMemberRequest,
OrganisationMemberRecord, OrganisationMemberRecord,
OrganisationMemberResponse, OrganisationMemberResponse,
} from "@sprint/shared"; } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { organisation } from "@/lib/server"; import { organisation } from "@/lib/server";
export function useOrganisations() { export function useOrganisations() {
return useQuery({ return useQuery({
queryKey: queryKeys.organisations.byUser(), queryKey: queryKeys.organisations.byUser(),
queryFn: organisation.byUser, queryFn: organisation.byUser,
}); });
} }
export function useOrganisationMembers(organisationId?: number | null) { export function useOrganisationMembers(organisationId?: number | null) {
return useQuery<OrganisationMemberResponse[]>({ return useQuery<OrganisationMemberResponse[]>({
queryKey: queryKeys.organisations.members(organisationId ?? 0), queryKey: queryKeys.organisations.members(organisationId ?? 0),
queryFn: () => organisation.members(organisationId ?? 0), queryFn: () => organisation.members(organisationId ?? 0),
enabled: Boolean(organisationId), enabled: Boolean(organisationId),
}); });
} }
export function useCreateOrganisation() { export function useCreateOrganisation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationKey: ["organisations", "create"], mutationKey: ["organisations", "create"],
mutationFn: organisation.create, mutationFn: organisation.create,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
}, },
}); });
} }
export function useUpdateOrganisation() { export function useUpdateOrganisation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationKey: ["organisations", "update"], mutationKey: ["organisations", "update"],
mutationFn: organisation.update, mutationFn: organisation.update,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
}, },
}); });
} }
export function useDeleteOrganisation() { export function useDeleteOrganisation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationKey: ["organisations", "delete"], mutationKey: ["organisations", "delete"],
mutationFn: organisation.remove, mutationFn: organisation.remove,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
}, },
}); });
} }
export function useAddOrganisationMember() { export function useAddOrganisationMember() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<OrganisationMemberRecord, Error, OrgAddMemberRequest>({ return useMutation<OrganisationMemberRecord, Error, OrgAddMemberRequest>({
mutationKey: ["organisations", "members", "add"], mutationKey: ["organisations", "members", "add"],
mutationFn: organisation.addMember, mutationFn: organisation.addMember,
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId), queryKey: queryKeys.organisations.members(variables.organisationId),
}); });
}, },
}); });
} }
export function useRemoveOrganisationMember() { export function useRemoveOrganisationMember() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationKey: ["organisations", "members", "remove"], mutationKey: ["organisations", "members", "remove"],
mutationFn: organisation.removeMember, mutationFn: organisation.removeMember,
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId), queryKey: queryKeys.organisations.members(variables.organisationId),
}); });
}, },
}); });
} }
export function useUpdateOrganisationMemberRole() { export function useUpdateOrganisationMemberRole() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationKey: ["organisations", "members", "update-role"], mutationKey: ["organisations", "members", "update-role"],
mutationFn: organisation.updateMemberRole, mutationFn: organisation.updateMemberRole,
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId), queryKey: queryKeys.organisations.members(variables.organisationId),
}); });
}, },
}); });
} }
export function useUploadOrganisationIcon() { export function useUploadOrganisationIcon() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<string, Error, { file: File; organisationId: number }>({ return useMutation<string, Error, { file: File; organisationId: number }>({
mutationKey: ["organisations", "upload-icon"], mutationKey: ["organisations", "upload-icon"],
mutationFn: ({ file, organisationId }) => organisation.uploadIcon(file, organisationId), mutationFn: ({ file, organisationId }) => organisation.uploadIcon(file, organisationId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
}, },
}); });
} }

View File

@@ -1,55 +1,55 @@
import type { import type {
ProjectCreateRequest, ProjectCreateRequest,
ProjectRecord, ProjectRecord,
ProjectResponse, ProjectResponse,
ProjectUpdateRequest, ProjectUpdateRequest,
} from "@sprint/shared"; } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { project } from "@/lib/server"; import { project } from "@/lib/server";
export function useProjects(organisationId?: number | null) { export function useProjects(organisationId?: number | null) {
return useQuery<ProjectResponse[]>({ return useQuery<ProjectResponse[]>({
queryKey: queryKeys.projects.byOrganisation(organisationId ?? 0), queryKey: queryKeys.projects.byOrganisation(organisationId ?? 0),
queryFn: () => project.byOrganisation(organisationId ?? 0), queryFn: () => project.byOrganisation(organisationId ?? 0),
enabled: Boolean(organisationId), enabled: Boolean(organisationId),
}); });
} }
export function useCreateProject() { export function useCreateProject() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<ProjectRecord, Error, ProjectCreateRequest>({ return useMutation<ProjectRecord, Error, ProjectCreateRequest>({
mutationKey: ["projects", "create"], mutationKey: ["projects", "create"],
mutationFn: project.create, mutationFn: project.create,
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.projects.byOrganisation(variables.organisationId), queryKey: queryKeys.projects.byOrganisation(variables.organisationId),
}); });
}, },
}); });
} }
export function useUpdateProject() { export function useUpdateProject() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<ProjectRecord, Error, ProjectUpdateRequest>({ return useMutation<ProjectRecord, Error, ProjectUpdateRequest>({
mutationKey: ["projects", "update"], mutationKey: ["projects", "update"],
mutationFn: project.update, mutationFn: project.update,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
}, },
}); });
} }
export function useDeleteProject() { export function useDeleteProject() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationKey: ["projects", "delete"], mutationKey: ["projects", "delete"],
mutationFn: project.remove, mutationFn: project.remove,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
}, },
}); });
} }

View File

@@ -4,45 +4,45 @@ import { queryKeys } from "@/lib/query/keys";
import { sprint } from "@/lib/server"; import { sprint } from "@/lib/server";
export function useSprints(projectId?: number | null) { export function useSprints(projectId?: number | null) {
return useQuery<SprintRecord[]>({ return useQuery<SprintRecord[]>({
queryKey: queryKeys.sprints.byProject(projectId ?? 0), queryKey: queryKeys.sprints.byProject(projectId ?? 0),
queryFn: () => sprint.byProject(projectId ?? 0), queryFn: () => sprint.byProject(projectId ?? 0),
enabled: Boolean(projectId), enabled: Boolean(projectId),
}); });
} }
export function useCreateSprint() { export function useCreateSprint() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<SprintRecord, Error, SprintCreateRequest>({ return useMutation<SprintRecord, Error, SprintCreateRequest>({
mutationKey: ["sprints", "create"], mutationKey: ["sprints", "create"],
mutationFn: sprint.create, mutationFn: sprint.create,
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(variables.projectId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(variables.projectId) });
}, },
}); });
} }
export function useUpdateSprint() { export function useUpdateSprint() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<SprintRecord, Error, SprintUpdateRequest>({ return useMutation<SprintRecord, Error, SprintUpdateRequest>({
mutationKey: ["sprints", "update"], mutationKey: ["sprints", "update"],
mutationFn: sprint.update, mutationFn: sprint.update,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all }); queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
}, },
}); });
} }
export function useDeleteSprint() { export function useDeleteSprint() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationKey: ["sprints", "delete"], mutationKey: ["sprints", "delete"],
mutationFn: sprint.remove, mutationFn: sprint.remove,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all }); queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
}, },
}); });
} }

View File

@@ -4,47 +4,47 @@ import { queryKeys } from "@/lib/query/keys";
import { timer } from "@/lib/server"; import { timer } from "@/lib/server";
export function useTimerState(issueId?: number | null, options?: { refetchInterval?: number }) { export function useTimerState(issueId?: number | null, options?: { refetchInterval?: number }) {
return useQuery<TimerState>({ return useQuery<TimerState>({
queryKey: queryKeys.timers.active(issueId ?? 0), queryKey: queryKeys.timers.active(issueId ?? 0),
queryFn: () => timer.get(issueId ?? 0), queryFn: () => timer.get(issueId ?? 0),
enabled: Boolean(issueId), enabled: Boolean(issueId),
refetchInterval: options?.refetchInterval, refetchInterval: options?.refetchInterval,
refetchIntervalInBackground: false, refetchIntervalInBackground: false,
}); });
} }
export function useInactiveTimers(issueId?: number | null, options?: { refetchInterval?: number }) { export function useInactiveTimers(issueId?: number | null, options?: { refetchInterval?: number }) {
return useQuery<TimerState[]>({ return useQuery<TimerState[]>({
queryKey: queryKeys.timers.inactive(issueId ?? 0), queryKey: queryKeys.timers.inactive(issueId ?? 0),
queryFn: () => timer.getInactive(issueId ?? 0), queryFn: () => timer.getInactive(issueId ?? 0),
enabled: Boolean(issueId), enabled: Boolean(issueId),
refetchInterval: options?.refetchInterval, refetchInterval: options?.refetchInterval,
refetchIntervalInBackground: false, refetchIntervalInBackground: false,
}); });
} }
export function useToggleTimer() { export function useToggleTimer() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TimerState, Error, TimerToggleRequest>({ return useMutation<TimerState, Error, TimerToggleRequest>({
mutationKey: ["timers", "toggle"], mutationKey: ["timers", "toggle"],
mutationFn: timer.toggle, mutationFn: timer.toggle,
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data); queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) }); queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
}, },
}); });
} }
export function useEndTimer() { export function useEndTimer() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TimerState, Error, TimerEndRequest>({ return useMutation<TimerState, Error, TimerEndRequest>({
mutationKey: ["timers", "end"], mutationKey: ["timers", "end"],
mutationFn: timer.end, mutationFn: timer.end,
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data); queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) }); 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"; import { user } from "@/lib/server";
export function useUserByUsername(username?: string | null) { export function useUserByUsername(username?: string | null) {
return useQuery<UserRecord>({ return useQuery<UserRecord>({
queryKey: queryKeys.users.byUsername(username ?? ""), queryKey: queryKeys.users.byUsername(username ?? ""),
queryFn: () => user.byUsername(username ?? ""), queryFn: () => user.byUsername(username ?? ""),
enabled: Boolean(username), enabled: Boolean(username),
}); });
} }
export function useUpdateUser() { export function useUpdateUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<UserRecord, Error, UserUpdateRequest>({ return useMutation<UserRecord, Error, UserUpdateRequest>({
mutationKey: ["users", "update"], mutationKey: ["users", "update"],
mutationFn: user.update, mutationFn: user.update,
onSuccess: (_data) => { onSuccess: (_data) => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
}, },
}); });
} }
export function useUploadAvatar() { export function useUploadAvatar() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<string, Error, File>({ return useMutation<string, Error, File>({
mutationKey: ["users", "upload-avatar"], mutationKey: ["users", "upload-avatar"],
mutationFn: user.uploadAvatar, mutationFn: user.uploadAvatar,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
}, },
}); });
} }

View File

@@ -1,33 +1,33 @@
// query key factory for granular cache invalidation // query key factory for granular cache invalidation
export const queryKeys = { export const queryKeys = {
organisations: { organisations: {
all: ["organisations"] as const, all: ["organisations"] as const,
byUser: () => [...queryKeys.organisations.all, "by-user"] as const, byUser: () => [...queryKeys.organisations.all, "by-user"] as const,
members: (orgId: number) => [...queryKeys.organisations.all, orgId, "members"] as const, members: (orgId: number) => [...queryKeys.organisations.all, orgId, "members"] as const,
}, },
projects: { projects: {
all: ["projects"] as const, all: ["projects"] as const,
byOrganisation: (orgId: number) => [...queryKeys.projects.all, "by-org", orgId] as const, byOrganisation: (orgId: number) => [...queryKeys.projects.all, "by-org", orgId] as const,
}, },
issues: { issues: {
all: ["issues"] as const, all: ["issues"] as const,
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const, byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
statusCount: (organisationId: number, status: string) => statusCount: (organisationId: number, status: string) =>
[...queryKeys.issues.all, "status-count", organisationId, status] as const, [...queryKeys.issues.all, "status-count", organisationId, status] as const,
}, },
sprints: { sprints: {
all: ["sprints"] as const, all: ["sprints"] as const,
byProject: (projectId: number) => [...queryKeys.sprints.all, "by-project", projectId] as const, byProject: (projectId: number) => [...queryKeys.sprints.all, "by-project", projectId] as const,
}, },
timers: { timers: {
all: ["timers"] as const, all: ["timers"] as const,
active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const, active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const,
inactive: (issueId: number) => [...queryKeys.timers.all, "inactive", issueId] as const, inactive: (issueId: number) => [...queryKeys.timers.all, "inactive", issueId] as const,
list: (issueId: number) => [...queryKeys.timers.all, "list", issueId] as const, list: (issueId: number) => [...queryKeys.timers.all, "list", issueId] as const,
}, },
users: { users: {
all: ["users"] as const, all: ["users"] as const,
byUsername: (username: string) => [...queryKeys.users.all, "by-username", username] 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 * as user from "@/lib/server/user";
export async function getErrorMessage(res: Response, fallback: string): Promise<string> { export async function getErrorMessage(res: Response, fallback: string): Promise<string> {
const error = await res.json().catch(() => res.text()); const error = await res.json().catch(() => res.text());
if (typeof error === "string") { if (typeof error === "string") {
return error || fallback; 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 ("error" in error && typeof error.error === "string") {
if ("details" in error && error.details) { return error.error || fallback;
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;
}
} }
return fallback; }
return fallback;
} }
export function parseError(error: ApiError | string | Error): string { export function parseError(error: ApiError | string | Error): string {
if (typeof error === "string") return error; if (typeof error === "string") return error;
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
if (error.details) { if (error.details) {
const messages = Object.values(error.details).flat(); const messages = Object.values(error.details).flat();
return messages.join(", "); return messages.join(", ");
} }
return error.error; return error.error;
} }

View File

@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function byProject(projectId: number): Promise<IssueResponse[]> { export async function byProject(projectId: number): Promise<IssueResponse[]> {
const url = new URL(`${getServerURL()}/issues/by-project`); const url = new URL(`${getServerURL()}/issues/by-project`);
url.searchParams.set("projectId", `${projectId}`); url.searchParams.set("projectId", `${projectId}`);
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issues by project (${res.status})`); const message = await getErrorMessage(res, `failed to get issues by project (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function create(request: IssueCreateRequest): Promise<IssueRecord> { export async function create(request: IssueCreateRequest): Promise<IssueRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue/create`, { const res = await fetch(`${getServerURL()}/issue/create`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to create issue (${res.status})`); const message = await getErrorMessage(res, `failed to create issue (${res.status})`);
throw new Error(message); throw new Error(message);
} }
const data = (await res.json()) as IssueRecord; const data = (await res.json()) as IssueRecord;
if (!data.id) { if (!data.id) {
throw new Error(`failed to create issue (${res.status})`); throw new Error(`failed to create issue (${res.status})`);
} }
return data; return data;
} }

View File

@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function remove(issueId: number): Promise<SuccessResponse> { export async function remove(issueId: number): Promise<SuccessResponse> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue/delete`, { const res = await fetch(`${getServerURL()}/issue/delete`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ id: issueId }), body: JSON.stringify({ id: issueId }),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete issue (${res.status})`); const message = await getErrorMessage(res, `failed to delete issue (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function replaceStatus(request: IssuesReplaceStatusRequest): Promise<ReplaceStatusResponse> { export async function replaceStatus(request: IssuesReplaceStatusRequest): Promise<ReplaceStatusResponse> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issues/replace-status`, { const res = await fetch(`${getServerURL()}/issues/replace-status`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to replace status (${res.status})`); const message = await getErrorMessage(res, `failed to replace status (${res.status})`);
throw new Error(message); throw new Error(message);
} }
return res.json(); return res.json();
} }

View File

@@ -3,18 +3,18 @@ import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function statusCount(organisationId: number, status: string): Promise<StatusCountResponse> { export async function statusCount(organisationId: number, status: string): Promise<StatusCountResponse> {
const url = new URL(`${getServerURL()}/issues/status-count`); const url = new URL(`${getServerURL()}/issues/status-count`);
url.searchParams.set("organisationId", `${organisationId}`); url.searchParams.set("organisationId", `${organisationId}`);
url.searchParams.set("status", status); url.searchParams.set("status", status);
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issue status count (${res.status})`); const message = await getErrorMessage(res, `failed to get issue status count (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function update(input: IssueUpdateRequest): Promise<IssueRecord> { export async function update(input: IssueUpdateRequest): Promise<IssueRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue/update`, { const res = await fetch(`${getServerURL()}/issue/update`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(input), body: JSON.stringify(input),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to update issue (${res.status})`); const message = await getErrorMessage(res, `failed to update issue (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function addMember(request: OrgAddMemberRequest): Promise<OrganisationMemberRecord> { export async function addMember(request: OrgAddMemberRequest): Promise<OrganisationMemberRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/add-member`, { const res = await fetch(`${getServerURL()}/organisation/add-member`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to add member (${res.status})`); const message = await getErrorMessage(res, `failed to add member (${res.status})`);
throw new Error(message); throw new Error(message);
} }
return res.json(); return res.json();
} }

View File

@@ -3,14 +3,14 @@ import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function byUser(): Promise<OrganisationResponse[]> { export async function byUser(): Promise<OrganisationResponse[]> {
const res = await fetch(`${getServerURL()}/organisations/by-user`, { const res = await fetch(`${getServerURL()}/organisations/by-user`, {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to get organisations (${res.status})`); const message = await getErrorMessage(res, `failed to get organisations (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function create(request: OrgCreateRequest): Promise<OrganisationRecord> { export async function create(request: OrgCreateRequest): Promise<OrganisationRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/create`, { const res = await fetch(`${getServerURL()}/organisation/create`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to create organisation (${res.status})`); const message = await getErrorMessage(res, `failed to create organisation (${res.status})`);
throw new Error(message); throw new Error(message);
} }
const data = (await res.json()) as OrganisationRecord; const data = (await res.json()) as OrganisationRecord;
if (!data.id) { if (!data.id) {
throw new Error(`failed to create organisation (${res.status})`); throw new Error(`failed to create organisation (${res.status})`);
} }
return data; return data;
} }

View File

@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function remove(organisationId: number): Promise<SuccessResponse> { export async function remove(organisationId: number): Promise<SuccessResponse> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/delete`, { const res = await fetch(`${getServerURL()}/organisation/delete`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ id: organisationId }), body: JSON.stringify({ id: organisationId }),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete organisation (${res.status})`); const message = await getErrorMessage(res, `failed to delete organisation (${res.status})`);
throw new Error(message); throw new Error(message);
} }
return res.json(); return res.json();
} }

View File

@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function members(organisationId: number): Promise<OrganisationMemberResponse[]> { export async function members(organisationId: number): Promise<OrganisationMemberResponse[]> {
const url = new URL(`${getServerURL()}/organisation/members`); const url = new URL(`${getServerURL()}/organisation/members`);
url.searchParams.set("organisationId", `${organisationId}`); url.searchParams.set("organisationId", `${organisationId}`);
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to get members (${res.status})`); const message = await getErrorMessage(res, `failed to get members (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function removeMember(request: OrgRemoveMemberRequest): Promise<SuccessResponse> { export async function removeMember(request: OrgRemoveMemberRequest): Promise<SuccessResponse> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/remove-member`, { const res = await fetch(`${getServerURL()}/organisation/remove-member`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to remove member (${res.status})`); const message = await getErrorMessage(res, `failed to remove member (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function update(input: OrgUpdateRequest): Promise<OrganisationRecord> { export async function update(input: OrgUpdateRequest): Promise<OrganisationRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/update`, { const res = await fetch(`${getServerURL()}/organisation/update`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(input), body: JSON.stringify(input),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to update organisation (${res.status})`); const message = await getErrorMessage(res, `failed to update organisation (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function updateMemberRole( export async function updateMemberRole(
request: OrgUpdateMemberRoleRequest, request: OrgUpdateMemberRoleRequest,
): Promise<OrganisationMemberRecord> { ): Promise<OrganisationMemberRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/update-member-role`, { const res = await fetch(`${getServerURL()}/organisation/update-member-role`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to update member role (${res.status})`); const message = await getErrorMessage(res, `failed to update member role (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function uploadIcon(file: File, organisationId: number): Promise<string> { export async function uploadIcon(file: File, organisationId: number): Promise<string> {
const MAX_FILE_SIZE = 5 * 1024 * 1024; const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
throw new Error("File size exceeds 5MB limit"); throw new Error("File size exceeds 5MB limit");
} }
if (!ALLOWED_TYPES.includes(file.type)) { if (!ALLOWED_TYPES.includes(file.type)) {
throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif"); throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif");
} }
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("organisationId", organisationId.toString()); formData.append("organisationId", organisationId.toString());
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const headers: HeadersInit = {}; const headers: HeadersInit = {};
if (csrfToken) headers["X-CSRF-Token"] = csrfToken; if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
const res = await fetch(`${getServerURL()}/organisation/upload-icon`, { const res = await fetch(`${getServerURL()}/organisation/upload-icon`, {
method: "POST", method: "POST",
headers, headers,
body: formData, body: formData,
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `Failed to upload icon (${res.status})`); const message = await getErrorMessage(res, `Failed to upload icon (${res.status})`);
throw new Error(message); throw new Error(message);
} }
const data = await res.json(); const data = await res.json();
if (data.iconURL) { if (data.iconURL) {
return 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 ".."; import { getErrorMessage } from "..";
export async function byOrganisation(organisationId: number): Promise<ProjectResponse[]> { export async function byOrganisation(organisationId: number): Promise<ProjectResponse[]> {
const url = new URL(`${getServerURL()}/projects/by-organisation`); const url = new URL(`${getServerURL()}/projects/by-organisation`);
url.searchParams.set("organisationId", `${organisationId}`); url.searchParams.set("organisationId", `${organisationId}`);
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to get projects by organisation (${res.status})`); const message = await getErrorMessage(res, `failed to get projects by organisation (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function create(request: ProjectCreateRequest): Promise<ProjectRecord> { export async function create(request: ProjectCreateRequest): Promise<ProjectRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/project/create`, { const res = await fetch(`${getServerURL()}/project/create`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to create project (${res.status})`); const message = await getErrorMessage(res, `failed to create project (${res.status})`);
throw new Error(message); throw new Error(message);
} }
const data = (await res.json()) as ProjectRecord; const data = (await res.json()) as ProjectRecord;
if (!data.id) { if (!data.id) {
throw new Error(`failed to create project (${res.status})`); throw new Error(`failed to create project (${res.status})`);
} }
return data; return data;
} }

View File

@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function remove(projectId: number): Promise<SuccessResponse> { export async function remove(projectId: number): Promise<SuccessResponse> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/project/delete`, { const res = await fetch(`${getServerURL()}/project/delete`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ id: projectId }), body: JSON.stringify({ id: projectId }),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete project (${res.status})`); const message = await getErrorMessage(res, `failed to delete project (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function update(input: ProjectUpdateRequest): Promise<ProjectRecord> { export async function update(input: ProjectUpdateRequest): Promise<ProjectRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/project/update`, { const res = await fetch(`${getServerURL()}/project/update`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(input), body: JSON.stringify(input),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to update project (${res.status})`); const message = await getErrorMessage(res, `failed to update project (${res.status})`);
throw new Error(message); throw new Error(message);
} }
return res.json(); return res.json();
} }

View File

@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function byProject(projectId: number): Promise<SprintRecord[]> { export async function byProject(projectId: number): Promise<SprintRecord[]> {
const url = new URL(`${getServerURL()}/sprints/by-project`); const url = new URL(`${getServerURL()}/sprints/by-project`);
url.searchParams.set("projectId", `${projectId}`); url.searchParams.set("projectId", `${projectId}`);
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to get sprints (${res.status})`); const message = await getErrorMessage(res, `failed to get sprints (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function create(input: SprintCreateRequest): Promise<SprintRecord> { export async function create(input: SprintCreateRequest): Promise<SprintRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/sprint/create`, { const res = await fetch(`${getServerURL()}/sprint/create`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
...input, ...input,
name: input.name.trim(), name: input.name.trim(),
}), }),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to create sprint (${res.status})`); const message = await getErrorMessage(res, `failed to create sprint (${res.status})`);
throw new Error(message); throw new Error(message);
} }
const data = (await res.json()) as SprintRecord; const data = (await res.json()) as SprintRecord;
if (!data.id) { if (!data.id) {
throw new Error(`failed to create sprint (${res.status})`); throw new Error(`failed to create sprint (${res.status})`);
} }
return data; return data;
} }

View File

@@ -3,22 +3,22 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function remove(sprintId: number): Promise<SuccessResponse> { export async function remove(sprintId: number): Promise<SuccessResponse> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/sprint/delete`, { const res = await fetch(`${getServerURL()}/sprint/delete`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ id: sprintId }), body: JSON.stringify({ id: sprintId }),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete sprint (${res.status})`); const message = await getErrorMessage(res, `failed to delete sprint (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function update(input: SprintUpdateRequest): Promise<SprintRecord> { export async function update(input: SprintUpdateRequest): Promise<SprintRecord> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/sprint/update`, { const res = await fetch(`${getServerURL()}/sprint/update`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
...input, ...input,
name: input.name?.trim(), name: input.name?.trim(),
}), }),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to update sprint (${res.status})`); const message = await getErrorMessage(res, `failed to update sprint (${res.status})`);
throw new Error(message); 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 ".."; import { getErrorMessage } from "..";
export async function end(request: TimerEndRequest): Promise<TimerState> { export async function end(request: TimerEndRequest): Promise<TimerState> {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/timer/end`, { const res = await fetch(`${getServerURL()}/timer/end`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(request), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to end timer (${res.status})`); const message = await getErrorMessage(res, `failed to end timer (${res.status})`);
throw new Error(message); throw new Error(message);
} }
return res.json(); return res.json();
} }

View File

@@ -3,17 +3,17 @@ import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function get(issueId: number): Promise<TimerState> { export async function get(issueId: number): Promise<TimerState> {
const url = new URL(`${getServerURL()}/timer/get`); const url = new URL(`${getServerURL()}/timer/get`);
url.searchParams.set("issueId", `${issueId}`); url.searchParams.set("issueId", `${issueId}`);
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to get timer (${res.status})`); const message = await getErrorMessage(res, `failed to get timer (${res.status})`);
throw new Error(message); throw new Error(message);
} }
return res.json(); return res.json();
} }

View File

@@ -3,18 +3,18 @@ import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
export async function getInactive(issueId: number): Promise<TimerState[]> { export async function getInactive(issueId: number): Promise<TimerState[]> {
const url = new URL(`${getServerURL()}/timer/get-inactive`); const url = new URL(`${getServerURL()}/timer/get-inactive`);
url.searchParams.set("issueId", `${issueId}`); url.searchParams.set("issueId", `${issueId}`);
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to get timers (${res.status})`); const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
throw new Error(message); throw new Error(message);
} }
const data = (await res.json()) as TimerState[]; const data = (await res.json()) as TimerState[];
return data ?? []; return data ?? [];
} }

View File

@@ -2,28 +2,28 @@ import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from ".."; import { getErrorMessage } from "..";
type TimerListInput = { type TimerListInput = {
limit?: number; limit?: number;
offset?: number; offset?: number;
}; };
export async function list(input: TimerListInput = {}): Promise<unknown> { export async function list(input: TimerListInput = {}): Promise<unknown> {
const url = new URL(`${getServerURL()}/timers`); const url = new URL(`${getServerURL()}/timers`);
if (input.limit != null) url.searchParams.set("limit", `${input.limit}`); if (input.limit != null) url.searchParams.set("limit", `${input.limit}`);
if (input.offset != null) url.searchParams.set("offset", `${input.offset}`); if (input.offset != null) url.searchParams.set("offset", `${input.offset}`);
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const headers: HeadersInit = {}; const headers: HeadersInit = {};
if (csrfToken) headers["X-CSRF-Token"] = csrfToken; if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
headers, headers,
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const message = await getErrorMessage(res, `failed to get timers (${res.status})`); const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
throw new Error(message); 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