diff --git a/packages/frontend/biome.json b/packages/frontend/biome.json index f2e6103..109f544 100644 --- a/packages/frontend/biome.json +++ b/packages/frontend/biome.json @@ -1,19 +1,19 @@ { - "root": false, - "$schema": "https://biomejs.dev/schemas/latest/schema.json", - "formatter": { - "enabled": true, - "formatWithErrors": false, - "indentStyle": "space", - "indentWidth": 4, - "lineWidth": 110 - }, - "css": { - "parser": { - "tailwindDirectives": true - } - }, - "files": { - "includes": ["**", "!dist", "!src-tauri/target", "!src-tauri/gen"] + "root": false, + "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 110 + }, + "css": { + "parser": { + "tailwindDirectives": true } + }, + "files": { + "includes": ["**", "!dist", "!src-tauri/target", "!src-tauri/gen"] + } } diff --git a/packages/frontend/components.json b/packages/frontend/components.json index 7bfb5bc..ffd5afa 100644 --- a/packages/frontend/components.json +++ b/packages/frontend/components.json @@ -1,22 +1,22 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/App.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "registries": {} + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/App.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 5438a2b..42270bf 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,55 +1,55 @@ { - "name": "@sprint/frontend", - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite --host", - "host": "NODE_ENV=production vite --host", - "build": "tsc && vite build", - "preview": "vite preview", - "tauri": "export __NV_DISABLE_EXPLICIT_SYNC=1 && tauri dev" - }, - "dependencies": { - "@iconify/react": "^6.0.2", - "@nsmr/pixelart-react": "^2.0.0", - "@phosphor-icons/react": "^2.1.10", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@sprint/shared": "workspace:*", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-query": "^5.90.19", - "@tanstack/react-query-devtools": "^5.91.2", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "lucide-react": "^0.561.0", - "next-themes": "^0.4.6", - "react": "^19.1.0", - "react-colorful": "^5.6.1", - "react-day-picker": "^9.13.0", - "react-dom": "^19.1.0", - "react-resizable-panels": "^4.0.15", - "react-router-dom": "^7.10.1", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18" - }, - "devDependencies": { - "@tauri-apps/cli": "^2", - "@types/node": "^25.0.1", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", - "tw-animate-css": "^1.4.0", - "typescript": "~5.8.3", - "vite": "^7.0.4" - } + "name": "@sprint/frontend", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host", + "host": "NODE_ENV=production vite --host", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "export __NV_DISABLE_EXPLICIT_SYNC=1 && tauri dev" + }, + "dependencies": { + "@iconify/react": "^6.0.2", + "@nsmr/pixelart-react": "^2.0.0", + "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@sprint/shared": "workspace:*", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query-devtools": "^5.91.2", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.561.0", + "next-themes": "^0.4.6", + "react": "^19.1.0", + "react-colorful": "^5.6.1", + "react-day-picker": "^9.13.0", + "react-dom": "^19.1.0", + "react-resizable-panels": "^4.0.15", + "react-router-dom": "^7.10.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@types/node": "^25.0.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "tw-animate-css": "^1.4.0", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } } diff --git a/packages/frontend/src-tauri/capabilities/default.json b/packages/frontend/src-tauri/capabilities/default.json index 7943979..f778364 100644 --- a/packages/frontend/src-tauri/capabilities/default.json +++ b/packages/frontend/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], - "permissions": ["core:default", "opener:default"] + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": ["core:default", "opener:default"] } diff --git a/packages/frontend/src-tauri/tauri.conf.json b/packages/frontend/src-tauri/tauri.conf.json index 5b80b92..db3a03e 100644 --- a/packages/frontend/src-tauri/tauri.conf.json +++ b/packages/frontend/src-tauri/tauri.conf.json @@ -1,32 +1,32 @@ { - "$schema": "https://schema.tauri.app/config/2", - "productName": "sprint", - "version": "0.1.0", - "identifier": "com.hex248.sprint", - "build": { - "beforeDevCommand": "bun run dev", - "devUrl": "http://localhost:1420/app", - "beforeBuildCommand": "bun run build", - "frontendDist": "../dist" - }, - "app": { - "windows": [ - { - "title": "Sprint", - "width": 1600, - "height": 900, - "minWidth": 640, - "minHeight": 360, - "decorations": false, - "url": "/app" - } - ], - "security": { - "csp": null - } - }, - "bundle": { - "active": true, - "targets": "all" + "$schema": "https://schema.tauri.app/config/2", + "productName": "sprint", + "version": "0.1.0", + "identifier": "com.hex248.sprint", + "build": { + "beforeDevCommand": "bun run dev", + "devUrl": "http://localhost:1420/app", + "beforeBuildCommand": "bun run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Sprint", + "width": 1600, + "height": 900, + "minWidth": 640, + "minHeight": 360, + "decorations": false, + "url": "/app" + } + ], + "security": { + "csp": null } + }, + "bundle": { + "active": true, + "targets": "all" + } } diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css index 3194cc0..187ef25 100644 --- a/packages/frontend/src/App.css +++ b/packages/frontend/src/App.css @@ -7,220 +7,220 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --radius-2xl: calc(var(--radius) + 8px); - --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - --color-personality: var(--personality); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-personality: var(--personality); } :root { - --font-weight: 450; - --radius: 0.625rem; - --personality: #f26d77; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(73.802% 0.00008 271.152); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --font-weight: 450; + --radius: 0.625rem; + --personality: #f26d77; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(73.802% 0.00008 271.152); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --font-weight: 400; - --personality: #f26d77; - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.105 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(100% 0.00011 271.152 / 0.22); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --font-weight: 400; + --personality: #f26d77; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.105 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(100% 0.00011 271.152 / 0.22); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } @layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } - ::selection { - background-color: var(--personality); - color: var(--background); - } + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + ::selection { + background-color: var(--personality); + color: var(--background); + } } * { - font-family: "Commit Mono", monospace; - font-optical-sizing: auto; - font-weight: var(--font-weight, 400); - font-style: normal; - font-variant-ligatures: none; + font-family: "Commit Mono", monospace; + font-optical-sizing: auto; + font-weight: var(--font-weight, 400); + font-style: normal; + font-variant-ligatures: none; } .font-200 { - font-weight: 200; + font-weight: 200; } .font-250 { - font-weight: 250; + font-weight: 250; } .font-300 { - font-weight: 300; + font-weight: 300; } .font-350 { - font-weight: 350; + font-weight: 350; } .font-400 { - font-weight: 400; + font-weight: 400; } .font-450 { - font-weight: 450; + font-weight: 450; } .font-500 { - font-weight: 500; + font-weight: 500; } .font-550 { - font-weight: 550; + font-weight: 550; } .font-600 { - font-weight: 600; + font-weight: 600; } .font-650 { - font-weight: 650; + font-weight: 650; } .font-700 { - font-weight: 700; + font-weight: 700; } .noselect { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } /* react-colorful */ .react-colorful { - width: 150px !important; - height: 150px !important; + width: 150px !important; + height: 150px !important; } .react-colorful__saturation { - border-radius: 0 !important; - /* cursor: pointer; */ + border-radius: 0 !important; + /* cursor: pointer; */ } .react-colorful__last-control { - border-radius: 0 !important; + border-radius: 0 !important; } .react-colorful__saturation-pointer { - width: 16px !important; - height: 16px !important; - border: 1px solid white !important; + width: 16px !important; + height: 16px !important; + border: 1px solid white !important; } .react-colorful__hue { - height: 10px !important; - cursor: pointer; + height: 10px !important; + cursor: pointer; } .react-colorful__hue-pointer { - width: 16px !important; - height: 16px !important; - border: 1px solid white !important; + width: 16px !important; + height: 16px !important; + border: 1px solid white !important; } [data-sonner-toast] { - transition: none !important; - padding: 10px 15px 10px 15px !important; - width: max-content !important; - max-width: 90vw !important; - display: inline-flex !important; - align-items: center !important; - white-space: nowrap !important; + transition: none !important; + padding: 10px 15px 10px 15px !important; + width: max-content !important; + max-width: 90vw !important; + display: inline-flex !important; + align-items: center !important; + white-space: nowrap !important; } diff --git a/packages/frontend/src/components/account.tsx b/packages/frontend/src/components/account.tsx index f2b688b..c8b97db 100644 --- a/packages/frontend/src/components/account.tsx +++ b/packages/frontend/src/components/account.tsx @@ -16,163 +16,160 @@ import { parseError } from "@/lib/server"; import { cn } from "@/lib/utils"; function Account({ trigger }: { trigger?: ReactNode }) { - const { user: currentUser, setUser } = useAuthenticatedSession(); - const updateUser = useUpdateUser(); + const { user: currentUser, setUser } = useAuthenticatedSession(); + const updateUser = useUpdateUser(); - const [open, setOpen] = useState(false); - const [name, setName] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [avatarURL, setAvatarUrl] = useState(null); - const [iconPreference, setIconPreference] = useState("lucide"); - const [error, setError] = useState(""); - const [submitAttempted, setSubmitAttempted] = useState(false); + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [avatarURL, setAvatarUrl] = useState(null); + const [iconPreference, setIconPreference] = useState("lucide"); + const [error, setError] = useState(""); + const [submitAttempted, setSubmitAttempted] = useState(false); - useEffect(() => { - if (!open) return; + useEffect(() => { + if (!open) return; - setName(currentUser.name); - setUsername(currentUser.username); - setAvatarUrl(currentUser.avatarURL || null); - setIconPreference((currentUser.iconPreference as IconStyle) ?? "lucide"); + setName(currentUser.name); + setUsername(currentUser.username); + setAvatarUrl(currentUser.avatarURL || null); + setIconPreference((currentUser.iconPreference as IconStyle) ?? "lucide"); - setPassword(""); - setError(""); - setSubmitAttempted(false); - }, [open, currentUser]); + setPassword(""); + setError(""); + setSubmitAttempted(false); + }, [open, currentUser]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setSubmitAttempted(true); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitAttempted(true); - if (name.trim() === "") { - return; - } + if (name.trim() === "") { + return; + } - try { - const data = await updateUser.mutateAsync({ - name: name.trim(), - password: password.trim() || undefined, - avatarURL, - iconPreference, - }); - setError(""); - setUser(data); - setPassword(""); - setOpen(false); + try { + const data = await updateUser.mutateAsync({ + name: name.trim(), + password: password.trim() || undefined, + avatarURL, + iconPreference, + }); + setError(""); + setUser(data); + setPassword(""); + setOpen(false); - toast.success("Account updated successfully", { - dismissible: false, - }); - } catch (err) { - const message = parseError(err as Error); - setError(message); + toast.success("Account updated successfully", { + dismissible: false, + }); + } catch (err) { + const message = parseError(err as Error); + setError(message); - toast.error(`Error updating account: ${message}`, { - dismissible: false, - }); - } - }; + toast.error(`Error updating account: ${message}`, { + dismissible: false, + }); + } + }; - return ( - - - {trigger || ( - - )} - + return ( + + + {trigger || ( + + )} + - - - Account - + + + Account + -
- - {avatarURL && ( - - )} - setName(e.target.value)} - validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} - submitAttempted={submitAttempted} - /> - setPassword(e.target.value)} - placeholder="Leave empty to keep current password" - hidden={true} - /> - + + + {avatarURL && ( + + )} + setName(e.target.value)} + validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} + submitAttempted={submitAttempted} + /> + setPassword(e.target.value)} + placeholder="Leave empty to keep current password" + hidden={true} + /> + -
-
- - -
-
- - -
+
+
+ + +
+
+ + +
+
+ + {error !== "" && } + +
+ +
+ + +
+ ); } export default Account; diff --git a/packages/frontend/src/components/add-member.tsx b/packages/frontend/src/components/add-member.tsx index d386b7f..38bc883 100644 --- a/packages/frontend/src/components/add-member.tsx +++ b/packages/frontend/src/components/add-member.tsx @@ -3,124 +3,121 @@ import { type FormEvent, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogClose, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, } from "@/components/ui/dialog"; import { Field } from "@/components/ui/field"; import { useAddOrganisationMember } from "@/lib/query/hooks"; import { parseError, user } from "@/lib/server"; export function AddMember({ - organisationId, - existingMembers, - trigger, - onSuccess, + organisationId, + existingMembers, + trigger, + onSuccess, }: { - organisationId: number; - existingMembers: string[]; - trigger?: React.ReactNode; - onSuccess?: (user: UserRecord) => void | Promise; + organisationId: number; + existingMembers: string[]; + trigger?: React.ReactNode; + onSuccess?: (user: UserRecord) => void | Promise; }) { - const [open, setOpen] = useState(false); - const [username, setUsername] = useState(""); - const [submitAttempted, setSubmitAttempted] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const addMember = useAddOrganisationMember(); + const [open, setOpen] = useState(false); + const [username, setUsername] = useState(""); + const [submitAttempted, setSubmitAttempted] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const addMember = useAddOrganisationMember(); - const reset = () => { - setUsername(""); - setSubmitAttempted(false); - setSubmitting(false); - setError(null); - }; + const reset = () => { + setUsername(""); + setSubmitAttempted(false); + setSubmitting(false); + setError(null); + }; - const onOpenChange = (nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) { - reset(); - } - }; + const onOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + reset(); + } + }; - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setSubmitAttempted(true); + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setSubmitAttempted(true); - if (username.trim() === "") { - return; - } + if (username.trim() === "") { + return; + } - if (existingMembers.includes(username)) { - setError("user is already a member of this organisation"); - return; - } + if (existingMembers.includes(username)) { + setError("user is already a member of this organisation"); + return; + } - setSubmitting(true); - try { - const userData: UserRecord = await user.byUsername(username); - const userId = userData.id; - await addMember.mutateAsync({ organisationId, userId, role: "member" }); - setOpen(false); - reset(); - try { - await onSuccess?.(userData); - } catch (actionErr) { - console.error(actionErr); - } - } catch (err) { - const message = parseError(err as Error); - console.error(err); - setError(message || "failed to add member"); - setSubmitting(false); + setSubmitting(true); + try { + const userData: UserRecord = await user.byUsername(username); + const userId = userData.id; + await addMember.mutateAsync({ organisationId, userId, role: "member" }); + setOpen(false); + reset(); + try { + await onSuccess?.(userData); + } catch (actionErr) { + console.error(actionErr); + } + } catch (err) { + const message = parseError(err as Error); + console.error(err); + setError(message || "failed to add member"); + setSubmitting(false); - toast.error(`Error adding member: ${message}`, { - dismissible: false, - }); - } - }; + toast.error(`Error adding member: ${message}`, { + dismissible: false, + }); + } + }; - return ( - - {trigger || } + return ( + + {trigger || } - - - Add Member - + + + Add Member + -
-
- setUsername(e.target.value)} - validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} - submitAttempted={submitAttempted} - placeholder="Enter username" - error={error || undefined} - /> + +
+ setUsername(e.target.value)} + validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} + submitAttempted={submitAttempted} + placeholder="Enter username" + error={error || undefined} + /> -
- - - - -
-
- - -
- ); +
+ + + + +
+ + + +
+ ); } diff --git a/packages/frontend/src/components/avatar.tsx b/packages/frontend/src/components/avatar.tsx index 5d4830d..9a53e8a 100644 --- a/packages/frontend/src/components/avatar.tsx +++ b/packages/frontend/src/components/avatar.tsx @@ -3,94 +3,94 @@ import Icon from "@/components/ui/icon"; import { cn } from "@/lib/utils"; const FALLBACK_COLOURS = [ - "bg-teal-500", - "bg-rose-500", - "bg-indigo-500", - "bg-amber-500", - "bg-cyan-500", - "bg-purple-500", - "bg-lime-500", - "bg-orange-500", - "bg-sky-500", - "bg-fuchsia-500", - "bg-green-500", - "bg-red-500", - "bg-violet-500", - "bg-yellow-500", - "bg-blue-500", - "bg-emerald-500", - "bg-pink-500", + "bg-teal-500", + "bg-rose-500", + "bg-indigo-500", + "bg-amber-500", + "bg-cyan-500", + "bg-purple-500", + "bg-lime-500", + "bg-orange-500", + "bg-sky-500", + "bg-fuchsia-500", + "bg-green-500", + "bg-red-500", + "bg-violet-500", + "bg-yellow-500", + "bg-blue-500", + "bg-emerald-500", + "bg-pink-500", ]; function hashStringToIndex(value: string, modulo: number) { - let hash = 0; - for (let i = 0; i < value.length; i++) { - hash = (hash * 31 + value.charCodeAt(i)) >>> 0; - } - return modulo === 0 ? 0 : hash % modulo; + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0; + } + return modulo === 0 ? 0 : hash % modulo; } function getInitials(username: string) { - username = username.trim(); + username = username.trim(); - const parts = username.split(/[^a-zA-Z0-9]+/).filter(Boolean); - if (parts.length === 0) return username.slice(0, 2).toUpperCase(); + const parts = username.split(/[^a-zA-Z0-9]+/).filter(Boolean); + if (parts.length === 0) return username.slice(0, 2).toUpperCase(); - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); } export default function Avatar({ - avatarURL: _avatarURL, - name: _name, - username, - size, - textClass = "text-xs", - strong = false, - className, + avatarURL: _avatarURL, + name: _name, + username, + size, + textClass = "text-xs", + strong = false, + className, }: { - avatarURL?: string | null; - name?: string; - username?: string; - size?: number; - textClass?: string; - strong?: boolean; - className?: string; + avatarURL?: string | null; + name?: string; + username?: string; + size?: number; + textClass?: string; + strong?: boolean; + className?: string; }) { - // if the username matches the authed user, use their avatarURL and name (avoid stale data) - const { user } = useSession(); - const avatarURL = !strong && username && user && username === user.username ? user.avatarURL : _avatarURL; - const name = !strong && username && user && username === user.username ? user.name : _name; + // if the username matches the authed user, use their avatarURL and name (avoid stale data) + const { user } = useSession(); + const avatarURL = !strong && username && user && username === user.username ? user.avatarURL : _avatarURL; + const name = !strong && username && user && username === user.username ? user.name : _name; - const backgroundClass = username - ? FALLBACK_COLOURS[hashStringToIndex(username, FALLBACK_COLOURS.length)] - : "bg-muted"; + const backgroundClass = username + ? FALLBACK_COLOURS[hashStringToIndex(username, FALLBACK_COLOURS.length)] + : "bg-muted"; - return ( -
- {avatarURL ? ( - Avatar - ) : name ? ( - {getInitials(name)} - ) : ( - - )} -
- ); + return ( +
+ {avatarURL ? ( + Avatar + ) : name ? ( + {getInitials(name)} + ) : ( + + )} +
+ ); } diff --git a/packages/frontend/src/components/issue-detail-pane.tsx b/packages/frontend/src/components/issue-detail-pane.tsx index ab4c787..836c9ec 100644 --- a/packages/frontend/src/components/issue-detail-pane.tsx +++ b/packages/frontend/src/components/issue-detail-pane.tsx @@ -2,37 +2,37 @@ import { useMemo } from "react"; import { IssueDetails } from "@/components/issue-details"; import { useSelection } from "@/components/selection-provider"; import { - useOrganisationMembers, - useSelectedIssue, - useSelectedOrganisation, - useSelectedProject, - useSprints, + useOrganisationMembers, + useSelectedIssue, + useSelectedOrganisation, + useSelectedProject, + useSprints, } from "@/lib/query/hooks"; export function IssueDetailPane() { - const { selectIssue } = useSelection(); - const selectedOrganisation = useSelectedOrganisation(); - const selectedProject = useSelectedProject(); - const issueData = useSelectedIssue(); - const { data: sprints = [] } = useSprints(selectedProject?.Project.id); - const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id); + const { selectIssue } = useSelection(); + const selectedOrganisation = useSelectedOrganisation(); + const selectedProject = useSelectedProject(); + const issueData = useSelectedIssue(); + const { data: sprints = [] } = useSprints(selectedProject?.Project.id); + const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id); - const members = useMemo(() => membersData.map((member) => member.User), [membersData]); - const statuses = selectedOrganisation?.Organisation.statuses ?? {}; + const members = useMemo(() => membersData.map((member) => member.User), [membersData]); + const statuses = selectedOrganisation?.Organisation.statuses ?? {}; - if (!issueData || !selectedProject || !selectedOrganisation) { - return null; - } + if (!issueData || !selectedProject || !selectedOrganisation) { + return null; + } - return ( - selectIssue(null)} - onDelete={() => selectIssue(null)} - /> - ); + return ( + selectIssue(null)} + onDelete={() => selectIssue(null)} + /> + ); } diff --git a/packages/frontend/src/components/issue-details.tsx b/packages/frontend/src/components/issue-details.tsx index 4bac70e..1ab2e5c 100644 --- a/packages/frontend/src/components/issue-details.tsx +++ b/packages/frontend/src/components/issue-details.tsx @@ -22,426 +22,418 @@ import { parseError } from "@/lib/server"; import { cn, issueID } from "@/lib/utils"; function assigneesToStringArray(assignees: { id: number }[]): string[] { - if (assignees.length === 0) return ["unassigned"]; - return assignees.map((a) => a.id.toString()); + if (assignees.length === 0) return ["unassigned"]; + return assignees.map((a) => a.id.toString()); } function stringArrayToAssigneeIds(assigneeIds: string[]): number[] { - return assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)); + return assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)); } export function IssueDetails({ - issueData, - projectKey, - sprints, - members, - statuses, - onClose, - onDelete, - showHeader = true, + issueData, + projectKey, + sprints, + members, + statuses, + onClose, + onDelete, + showHeader = true, }: { - issueData: IssueResponse; - projectKey: string; - sprints: SprintRecord[]; - members: UserRecord[]; - statuses: Record; - onClose: () => void; - onDelete?: () => void; - showHeader?: boolean; + issueData: IssueResponse; + projectKey: string; + sprints: SprintRecord[]; + members: UserRecord[]; + statuses: Record; + onClose: () => void; + onDelete?: () => void; + showHeader?: boolean; }) { - const { user } = useSession(); - const updateIssue = useUpdateIssue(); - const deleteIssue = useDeleteIssue(); + const { user } = useSession(); + const updateIssue = useUpdateIssue(); + const deleteIssue = useDeleteIssue(); - const [assigneeIds, setAssigneeIds] = useState([]); - const [sprintId, setSprintId] = useState("unassigned"); - const [status, setStatus] = useState(""); - const [deleteOpen, setDeleteOpen] = useState(false); - const [linkCopied, setLinkCopied] = useState(false); - const copyTimeoutRef = useRef(null); + const [assigneeIds, setAssigneeIds] = useState([]); + const [sprintId, setSprintId] = useState("unassigned"); + const [status, setStatus] = useState(""); + const [deleteOpen, setDeleteOpen] = useState(false); + const [linkCopied, setLinkCopied] = useState(false); + const copyTimeoutRef = useRef(null); - const [title, setTitle] = useState(""); - const [originalTitle, setOriginalTitle] = useState(""); - const [isSavingTitle, setIsSavingTitle] = useState(false); + const [title, setTitle] = useState(""); + const [originalTitle, setOriginalTitle] = useState(""); + const [isSavingTitle, setIsSavingTitle] = useState(false); - const [description, setDescription] = useState(""); - const [originalDescription, setOriginalDescription] = useState(""); - const [isEditingDescription, setIsEditingDescription] = useState(false); - const [isSavingDescription, setIsSavingDescription] = useState(false); - const descriptionRef = useRef(null); + const [description, setDescription] = useState(""); + const [originalDescription, setOriginalDescription] = useState(""); + const [isEditingDescription, setIsEditingDescription] = useState(false); + const [isSavingDescription, setIsSavingDescription] = useState(false); + const descriptionRef = useRef(null); - const isAssignee = assigneeIds.some((id) => user?.id === Number(id)); - const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned"); - const hasMultipleAssignees = actualAssigneeIds.length > 1; + const isAssignee = assigneeIds.some((id) => user?.id === Number(id)); + const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned"); + const hasMultipleAssignees = actualAssigneeIds.length > 1; - useEffect(() => { - setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); - setAssigneeIds(assigneesToStringArray(issueData.Assignees)); - setStatus(issueData.Issue.status); - setTitle(issueData.Issue.title); - setOriginalTitle(issueData.Issue.title); - setDescription(issueData.Issue.description); - setOriginalDescription(issueData.Issue.description); + useEffect(() => { + setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); + setAssigneeIds(assigneesToStringArray(issueData.Assignees)); + setStatus(issueData.Issue.status); + setTitle(issueData.Issue.title); + setOriginalTitle(issueData.Issue.title); + setDescription(issueData.Issue.description); + setOriginalDescription(issueData.Issue.description); + setIsEditingDescription(false); + }, [issueData]); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + window.clearTimeout(copyTimeoutRef.current); + } + }; + }, []); + + const handleSprintChange = async (value: string) => { + setSprintId(value); + const newSprintId = value === "unassigned" ? null : Number(value); + + try { + await updateIssue.mutateAsync({ + id: issueData.Issue.id, + sprintId: newSprintId, + }); + toast.success( + <> + Successfully updated sprint to{" "} + {value === "unassigned" ? ( + "Unassigned" + ) : ( + 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" + ) : ( + 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( +
+ Updated assignees to {displayText} for {issueID(projectKey, issueData.Issue.number)} +
, + { + 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{" "} + + , + { dismissible: false }, + ); + } catch (error) { + console.error("error updating status:", error); + setStatus(issueData.Issue.status); + toast.error(`Error updating status: ${parseError(error as Error)}`, { + dismissible: false, + }); + } + }; + + const handleDelete = () => { + setDeleteOpen(true); + }; + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(window.location.href); + setLinkCopied(true); + if (copyTimeoutRef.current) { + window.clearTimeout(copyTimeoutRef.current); + } + copyTimeoutRef.current = window.setTimeout(() => { + setLinkCopied(false); + copyTimeoutRef.current = null; + }, 1500); + } catch (error) { + console.error("error copying issue link:", error); + } + }; + + const handleTitleSave = async () => { + const trimmedTitle = title.trim(); + if (trimmedTitle === "" || trimmedTitle === originalTitle) { + setTitle(originalTitle); + return; + } + + setIsSavingTitle(true); + try { + await updateIssue.mutateAsync({ + id: issueData.Issue.id, + title: trimmedTitle, + }); + setOriginalTitle(trimmedTitle); + toast.success(`${issueID(projectKey, issueData.Issue.number)} Title updated`); + } catch (error) { + console.error("error updating title:", error); + setTitle(originalTitle); + } finally { + setIsSavingTitle(false); + } + }; + + const handleDescriptionSave = async () => { + const trimmedDescription = description.trim(); + if (trimmedDescription === originalDescription) { + if (trimmedDescription === "") { setIsEditingDescription(false); - }, [issueData]); + } + return; + } - useEffect(() => { - return () => { - if (copyTimeoutRef.current) { - window.clearTimeout(copyTimeoutRef.current); - } - }; - }, []); + setIsSavingDescription(true); + try { + await updateIssue.mutateAsync({ + id: issueData.Issue.id, + description: trimmedDescription, + }); + setOriginalDescription(trimmedDescription); + setDescription(trimmedDescription); + toast.success(`${issueID(projectKey, issueData.Issue.number)} Description updated`); + if (trimmedDescription === "") { + setIsEditingDescription(false); + } + } catch (error) { + console.error("error updating description:", error); + setDescription(originalDescription); + } finally { + setIsSavingDescription(false); + } + }; - const handleSprintChange = async (value: string) => { - setSprintId(value); - const newSprintId = value === "unassigned" ? null : Number(value); + const handleConfirmDelete = async () => { + try { + await deleteIssue.mutateAsync(issueData.Issue.id); + onDelete?.(); + toast.success(`Deleted issue ${issueID(projectKey, issueData.Issue.number)}`, { + dismissible: false, + }); + } catch (error) { + console.error(`error deleting issue ${issueID(projectKey, issueData.Issue.number)}`, error); + toast.error( + `Error deleting issue ${issueID(projectKey, issueData.Issue.number)}: ${parseError(error as Error)}`, + { + dismissible: false, + }, + ); + } finally { + setDeleteOpen(false); + } + }; - try { - await updateIssue.mutateAsync({ - id: issueData.Issue.id, - sprintId: newSprintId, - }); - toast.success( - <> - Successfully updated sprint to{" "} - {value === "unassigned" ? ( - "Unassigned" - ) : ( - 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" - ) : ( - 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( -
- Updated assignees to {displayText} for {issueID(projectKey, issueData.Issue.number)} -
, - { - 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{" "} - - , - { 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 ( -
- {showHeader && ( -
- -

- {issueID(projectKey, issueData.Issue.number)} -

-
-
- - {linkCopied ? : } - - - - - - - -
-
- )} - -
-
- ( - - - - )} - /> -
- 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")} - /> -
-
- {description || isEditingDescription ? ( -