halfway commit to allow collaboration

This commit is contained in:
2025-09-28 05:58:29 -04:00
parent b79d3ac2cf
commit af8371ed84
34 changed files with 4418 additions and 1223 deletions

View File

@@ -18,6 +18,7 @@
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"bcrypt": "^6.0.0",
"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",
@@ -34,6 +35,8 @@
"zustand": "^5.0.8", "zustand": "^5.0.8",
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/bun": "^1.2.22",
"@types/react": "^18.2.64", "@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.2.21",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
@@ -378,6 +381,10 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
@@ -488,6 +495,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.8.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
@@ -514,6 +523,8 @@
"builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="], "builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="],
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
"cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
@@ -1030,7 +1041,9 @@
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], "node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"node-releases": ["node-releases@2.0.21", "", {}, "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw=="], "node-releases": ["node-releases@2.0.21", "", {}, "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw=="],
@@ -1398,6 +1411,8 @@
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],

View File

@@ -24,6 +24,7 @@
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"bcrypt": "^6.0.0",
"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",
@@ -40,6 +41,8 @@
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/bun": "^1.2.22",
"@types/react": "^18.2.64", "@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.2.21",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router"; import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
@@ -11,33 +11,22 @@ import ChatPage from "@/pages/ChatPage";
import SettingsPage from "@/pages/SettingsPage"; import SettingsPage from "@/pages/SettingsPage";
import NotFoundPage from "@/pages/NotFoundPage"; import NotFoundPage from "@/pages/NotFoundPage";
// import { useAuthStore } from "@/stores/authStore"; import { queryClient } from "@/lib/api-client";
// import { useUiStore } from "@/stores/uiStore"; import { useAuthStore } from "@/stores/authStore";
import ErrorBoundary from "@/components/common/ErrorBoundary"; import ErrorBoundary from "@/components/common/ErrorBoundary";
import { Home } from "lucide-react"; import { Home } from "lucide-react";
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
retry: (failureCount, error: any) => {
if (error?.status === 401) return false;
return failureCount < 3;
},
},
},
});
// Protected Route wrapper // Protected Route wrapper
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
// const { isAuthenticated } = useAuthStore(); const { isAuthenticated } = useAuthStore();
// if (!isAuthenticated) {
// return <Navigate to="/login" replace />; // Enable this when you want to enforce authentication
// } if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>; return <>{children}</>;
}; };

View File

@@ -0,0 +1,276 @@
import * as React from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
// Basic dropdown styles
const dropdownStyles = {
content: `
min-w-[220px]
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-md
p-1
shadow-lg
z-50
animate-in
fade-in-80
data-[state=open]:animate-in
data-[state=closed]:animate-out
data-[state=closed]:fade-out-0
data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95
data-[state=open]:zoom-in-95
data-[side=bottom]:slide-in-from-top-2
data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2
data-[side=top]:slide-in-from-bottom-2
`,
item: `
relative
flex
cursor-pointer
select-none
items-center
rounded-sm
px-2
py-1.5
text-sm
outline-none
transition-colors
hover:bg-gray-100 dark:hover:bg-gray-700
focus:bg-gray-100 dark:focus:bg-gray-700
data-[disabled]:pointer-events-none
data-[disabled]:opacity-50
`,
separator: `
-mx-1
my-1
h-px
bg-gray-200 dark:bg-gray-700
`,
label: `
px-2
py-1.5
text-sm
font-semibold
text-gray-500 dark:text-gray-400
`,
destructive: `
text-red-600 dark:text-red-400
hover:bg-red-50 dark:hover:bg-red-900/20
focus:bg-red-50 dark:focus:bg-red-900/20
`,
};
// Utility to combine class names
const cn = (...classes: (string | undefined | false)[]) => {
return classes.filter(Boolean).join(" ");
};
// Root dropdown menu
export const DirectDropdownMenu = DropdownMenu.Root;
export const DirectDropdownMenuTrigger = DropdownMenu.Trigger;
// Content component with styling
interface DirectDropdownMenuContentProps
extends React.ComponentProps<typeof DropdownMenu.Content> {
className?: string;
sideOffset?: number;
}
export const DirectDropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenu.Content>,
DirectDropdownMenuContentProps
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenu.Portal>
<DropdownMenu.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
dropdownStyles.content.replace(/\s+/g, " ").trim(),
className,
)}
{...props}
/>
</DropdownMenu.Portal>
));
DirectDropdownMenuContent.displayName = "DirectDropdownMenuContent";
// Menu item component
interface DirectDropdownMenuItemProps
extends React.ComponentProps<typeof DropdownMenu.Item> {
className?: string;
destructive?: boolean;
}
export const DirectDropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenu.Item>,
DirectDropdownMenuItemProps
>(({ className, destructive, ...props }, ref) => (
<DropdownMenu.Item
ref={ref}
className={cn(
dropdownStyles.item.replace(/\s+/g, " ").trim(),
destructive && dropdownStyles.destructive.replace(/\s+/g, " ").trim(),
className,
)}
{...props}
/>
));
DirectDropdownMenuItem.displayName = "DirectDropdownMenuItem";
// Separator component
interface DirectDropdownMenuSeparatorProps
extends React.ComponentProps<typeof DropdownMenu.Separator> {
className?: string;
}
export const DirectDropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenu.Separator>,
DirectDropdownMenuSeparatorProps
>(({ className, ...props }, ref) => (
<DropdownMenu.Separator
ref={ref}
className={cn(
dropdownStyles.separator.replace(/\s+/g, " ").trim(),
className,
)}
{...props}
/>
));
DirectDropdownMenuSeparator.displayName = "DirectDropdownMenuSeparator";
// Label component
interface DirectDropdownMenuLabelProps
extends React.ComponentProps<typeof DropdownMenu.Label> {
className?: string;
}
export const DirectDropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenu.Label>,
DirectDropdownMenuLabelProps
>(({ className, ...props }, ref) => (
<DropdownMenu.Label
ref={ref}
className={cn(dropdownStyles.label.replace(/\s+/g, " ").trim(), className)}
{...props}
/>
));
DirectDropdownMenuLabel.displayName = "DirectDropdownMenuLabel";
// Checkbox item component
interface DirectDropdownMenuCheckboxItemProps
extends React.ComponentProps<typeof DropdownMenu.CheckboxItem> {
className?: string;
children: React.ReactNode;
}
export const DirectDropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenu.CheckboxItem>,
DirectDropdownMenuCheckboxItemProps
>(({ className, children, ...props }, ref) => (
<DropdownMenu.CheckboxItem
ref={ref}
className={cn(
dropdownStyles.item.replace(/\s+/g, " ").trim(),
"pl-8",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenu.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenu.ItemIndicator>
</span>
{children}
</DropdownMenu.CheckboxItem>
));
DirectDropdownMenuCheckboxItem.displayName = "DirectDropdownMenuCheckboxItem";
// Sub menu components
export const DirectDropdownMenuSub = DropdownMenu.Sub;
interface DirectDropdownMenuSubTriggerProps
extends React.ComponentProps<typeof DropdownMenu.SubTrigger> {
className?: string;
children: React.ReactNode;
}
export const DirectDropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenu.SubTrigger>,
DirectDropdownMenuSubTriggerProps
>(({ className, children, ...props }, ref) => (
<DropdownMenu.SubTrigger
ref={ref}
className={cn(dropdownStyles.item.replace(/\s+/g, " ").trim(), className)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenu.SubTrigger>
));
DirectDropdownMenuSubTrigger.displayName = "DirectDropdownMenuSubTrigger";
interface DirectDropdownMenuSubContentProps
extends React.ComponentProps<typeof DropdownMenu.SubContent> {
className?: string;
}
export const DirectDropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenu.SubContent>,
DirectDropdownMenuSubContentProps
>(({ className, ...props }, ref) => (
<DropdownMenu.SubContent
ref={ref}
className={cn(
dropdownStyles.content.replace(/\s+/g, " ").trim(),
className,
)}
{...props}
/>
));
DirectDropdownMenuSubContent.displayName = "DirectDropdownMenuSubContent";
// Example usage component to test the dropdown
export const DirectDropdownExample: React.FC = () => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div className="p-4">
<DirectDropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DirectDropdownMenuTrigger asChild>
<button className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
Open Menu
</button>
</DirectDropdownMenuTrigger>
<DirectDropdownMenuContent align="end" className="w-56">
<DirectDropdownMenuLabel>My Account</DirectDropdownMenuLabel>
<DirectDropdownMenuSeparator />
<DirectDropdownMenuItem
onClick={() => console.log("Profile clicked")}
>
<span>Profile</span>
</DirectDropdownMenuItem>
<DirectDropdownMenuItem
onClick={() => console.log("Settings clicked")}
>
<span>Settings</span>
</DirectDropdownMenuItem>
<DirectDropdownMenuSeparator />
<DirectDropdownMenuItem
destructive
onClick={() => console.log("Logout clicked")}
>
<span>Log out</span>
</DirectDropdownMenuItem>
</DirectDropdownMenuContent>
</DirectDropdownMenu>
</div>
);
};

View File

@@ -1,11 +1,15 @@
import React from "react"; // src/components/layout/MemberList.tsx - Enhanced with role management
import React, { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { Crown, Shield, UserIcon } from "lucide-react"; import { Crown, Shield, UserIcon } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Role } from "@/types/database"; import { Role } from "@/types/database";
import { useInstanceMembers } from "@/hooks/useServers"; import { useInstanceMembers } from "@/hooks/useServers";
import { useAuthStore } from "@/stores/authStore";
import { User } from "@/types/database"; import { User } from "@/types/database";
import { UserRoleModal } from "@/components/modals/UserRoleModal";
// Status color utility // Status color utility
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -23,6 +27,9 @@ const getStatusColor = (status: string) => {
interface MemberItemProps { interface MemberItemProps {
member: User; member: User;
instanceId: string;
currentUserRole: string;
canManageRoles: boolean;
isOwner?: boolean; isOwner?: boolean;
} }
@@ -43,13 +50,32 @@ const getRoleInfo = (role: string) => {
} }
}; };
const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => { const MemberItem: React.FC<MemberItemProps> = ({
const { instanceId } = useParams(); member,
instanceId,
currentUserRole,
canManageRoles,
isOwner = false,
}) => {
const [showUserModal, setShowUserModal] = useState(false);
const userRole = getUserRoleForInstance(member.roles, instanceId || ""); const userRole = getUserRoleForInstance(member.roles, instanceId || "");
const roleInfo = getRoleInfo(userRole); const roleInfo = getRoleInfo(userRole);
const handleMemberClick = () => {
if (canManageRoles && !member.admin) {
setShowUserModal(true);
}
};
return ( return (
<div className="panel-button"> <>
<Button
variant="ghost"
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
onClick={handleMemberClick}
disabled={!canManageRoles || member.admin}
>
<div className="flex items-center gap-3 w-full">
<div className="relative"> <div className="relative">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage <AvatarImage
@@ -66,7 +92,7 @@ const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
/> />
</div> </div>
<div className="ml-3 flex-1 min-w-0"> <div className="flex-1 min-w-0 text-left">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isOwner && ( {isOwner && (
<Crown size={12} className="text-yellow-500 flex-shrink-0" /> <Crown size={12} className="text-yellow-500 flex-shrink-0" />
@@ -92,12 +118,49 @@ const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
)} )}
</div> </div>
</div> </div>
</Button>
{/* User Role Modal */}
<UserRoleModal
isOpen={showUserModal}
onClose={() => setShowUserModal(false)}
user={member}
instanceId={instanceId}
currentUserRole={currentUserRole}
canManageRoles={canManageRoles}
/>
</>
); );
}; };
const MemberList: React.FC = () => { const MemberList: React.FC = () => {
const { instanceId } = useParams(); const { instanceId } = useParams();
const { data: members, isLoading } = useInstanceMembers(instanceId); const { data: members, isLoading } = useInstanceMembers(instanceId);
const { user: currentUser } = useAuthStore();
// Check if current user can manage roles
const canManageRoles = React.useMemo(() => {
if (!currentUser || !instanceId) return false;
// Global admins can manage roles
if (currentUser.admin) return true;
// Check if user is admin or mod in this instance
const userRole = currentUser.roles.find(
(role) => role.instanceId === instanceId,
);
return userRole && (userRole.role === "admin" || userRole.role === "mod");
}, [currentUser, instanceId]);
const currentUserRole = React.useMemo(() => {
if (!currentUser || !instanceId) return "member";
if (currentUser.admin) return "admin";
const userRole = currentUser.roles.find(
(role) => role.instanceId === instanceId,
);
return userRole?.role || "member";
}, [currentUser, instanceId]);
if (!instanceId) { if (!instanceId) {
return null; return null;
@@ -177,6 +240,9 @@ const MemberList: React.FC = () => {
<MemberItem <MemberItem
key={member.id} key={member.id}
member={member} member={member}
instanceId={instanceId}
currentUserRole={currentUserRole}
canManageRoles={canManageRoles}
isOwner={false} isOwner={false}
/> />
))} ))}

View File

@@ -10,34 +10,45 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useServers } from "@/hooks/useServers"; import { useServers } from "@/hooks/useServers";
import { useUiStore } from "@/stores/uiStore"; import { useUiStore } from "@/stores/uiStore";
import { useAuthStore } from "@/stores/authStore";
import ServerIcon from "@/components/server/ServerIcon"; import ServerIcon from "@/components/server/ServerIcon";
import { getAccessibleInstances, isGlobalAdmin } from "@/utils/permissions";
const ServerSidebar: React.FC = () => { const ServerSidebar: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { instanceId } = useParams(); const { instanceId } = useParams();
const { data: servers, isLoading } = useServers(); const { data: allServers = [], isLoading } = useServers();
const { openCreateServer, setActiveInstance, getSelectedChannelForInstance } = const { openCreateServer, setActiveInstance, getSelectedChannelForInstance } =
useUiStore(); useUiStore();
const { user: currentUser } = useAuthStore();
// Filter servers based on user permissions
const accessibleServers = getAccessibleInstances(currentUser, allServers);
const canCreateServer = isGlobalAdmin(currentUser);
const handleServerClick = (serverId: string) => { const handleServerClick = (serverId: string) => {
setActiveInstance(serverId); setActiveInstance(serverId);
const lastChannelId = getSelectedChannelForInstance(serverId); const lastChannelId = getSelectedChannelForInstance(serverId);
console.log(servers);
if (lastChannelId) { if (lastChannelId) {
navigate(`/channels/${serverId}/${lastChannelId}`); navigate(`/channels/${serverId}/${lastChannelId}`);
} else { } else {
// Fallback: navigate to the server, let the page component handle finding a channel // Fallback: navigate to the server, let the page component handle finding a channel
// A better UX would be to find and navigate to the first channel here.
navigate(`/channels/${serverId}`); navigate(`/channels/${serverId}`);
} }
}; };
const handleHomeClick = () => { const handleHomeClick = () => {
setActiveInstance(null); setActiveInstance(null);
navigate("/channels/@me"); navigate("/channels/@me");
}; };
const handleCreateServer = () => {
if (canCreateServer) {
openCreateServer();
}
};
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="sidebar-primary flex flex-col items-center h-full py-2 space-y-2"> <div className="sidebar-primary flex flex-col items-center h-full py-2 space-y-2">
@@ -47,8 +58,10 @@ const ServerSidebar: React.FC = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={` w-12 h-12 ml-0 ${ className={`w-12 h-12 ml-0 rounded-2xl hover:rounded-xl transition-all duration-200 ${
!instanceId || instanceId === "@me" ? "active" : "" !instanceId || instanceId === "@me"
? "bg-primary text-primary-foreground rounded-xl"
: "hover:bg-primary/10"
}`} }`}
onClick={handleHomeClick} onClick={handleHomeClick}
> >
@@ -56,7 +69,7 @@ const ServerSidebar: React.FC = () => {
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Direct Messages</p> <p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -69,8 +82,8 @@ const ServerSidebar: React.FC = () => {
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div> <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div> </div>
) : ( ) : accessibleServers.length > 0 ? (
servers?.map((server) => ( accessibleServers.map((server) => (
<Tooltip key={server.id}> <Tooltip key={server.id}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div> <div>
@@ -86,17 +99,34 @@ const ServerSidebar: React.FC = () => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)) ))
) : currentUser ? (
<div className="text-center py-4 px-2">
<div className="text-xs text-concord-secondary mb-2">
No servers available
</div>
{canCreateServer && (
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={handleCreateServer}
>
Create One
</Button>
)} )}
</div> </div>
) : null}
</div>
{/* Add Server Button */} {/* Add Server Button - Only show if user can create servers */}
{canCreateServer && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="w-12 h-12 ml-3 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200" className="w-12 h-12 ml-3 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
onClick={openCreateServer} onClick={handleCreateServer}
> >
<Plus size={24} /> <Plus size={24} />
</Button> </Button>
@@ -105,6 +135,7 @@ const ServerSidebar: React.FC = () => {
<p>Add a Server</p> <p>Add a Server</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
</div> </div>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -18,7 +18,7 @@ import {
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
import { useUiStore } from "@/stores/uiStore"; import { useUiStore } from "@/stores/uiStore";
import { SAMPLE_USERS } from "@/hooks/useServers"; import { useLogout } from "@/hooks/useAuth";
// Status color utility // Status color utility
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -46,6 +46,8 @@ const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
onStatusChange, onStatusChange,
children, children,
}) => { }) => {
const { mutate: logout } = useLogout();
const statusOptions = [ const statusOptions = [
{ value: "online", label: "Online", color: "bg-status-online" }, { value: "online", label: "Online", color: "bg-status-online" },
{ value: "away", label: "Away", color: "bg-status-away" }, { value: "away", label: "Away", color: "bg-status-away" },
@@ -79,7 +81,7 @@ const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => useAuthStore.getState().logout()} onClick={() => logout()}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
Log Out Log Out
@@ -212,12 +214,19 @@ const UserPanel: React.FC = () => {
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false); const [isDeafened, setIsDeafened] = useState(false);
const displayUser = user || SAMPLE_USERS.find((u) => u.id === "current"); // If no authenticated user, show login prompt
if (!user) {
if (!displayUser) {
return ( return (
<div className="flex-shrink-0 p-2 bg-concord-tertiary"> <div className="flex-shrink-0 p-2 bg-concord-tertiary border-t border-sidebar">
<div className="text-concord-secondary text-sm">No user data</div> <div className="text-center text-concord-secondary text-sm">
<Button
variant="outline"
size="sm"
onClick={() => (window.location.href = "/login")}
>
Login Required
</Button>
</div>
</div> </div>
); );
} }
@@ -225,6 +234,7 @@ const UserPanel: React.FC = () => {
const handleStatusChange = (newStatus: string) => { const handleStatusChange = (newStatus: string) => {
console.log("Status change to:", newStatus); console.log("Status change to:", newStatus);
// TODO: Implement API call to update user status // TODO: Implement API call to update user status
// You can add a useUpdateUserStatus hook here
}; };
const handleMuteToggle = () => setIsMuted(!isMuted); const handleMuteToggle = () => setIsMuted(!isMuted);
@@ -240,20 +250,20 @@ const UserPanel: React.FC = () => {
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar"> <div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
{/* User Info with Dropdown */} {/* User Info with Dropdown */}
<UserStatusDropdown <UserStatusDropdown
currentStatus={displayUser.status} currentStatus={user.status}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
> >
<Button <Button
variant="ghost" variant="ghost"
className="flex-1 flex items-center h-auto p-1 rounded-md hover:bg-concord-secondary" className="flex-1 flex items-center h-auto p-1 rounded-md hover:bg-concord-secondary"
> >
<UserAvatar user={displayUser} size="md" /> <UserAvatar user={user} size="md" />
<div className="ml-2 flex-1 min-w-0 text-left"> <div className="ml-2 flex-1 min-w-0 text-left">
<div className="text-sm font-medium text-concord-primary truncate"> <div className="text-sm font-medium text-concord-primary truncate">
{displayUser.nickname || displayUser.username} {user.nickname || user.username}
</div> </div>
<div className="text-xs text-concord-secondary truncate capitalize"> <div className="text-xs text-concord-secondary truncate capitalize">
{displayUser.status} {user.status}
</div> </div>
</div> </div>
</Button> </Button>

View File

@@ -0,0 +1,154 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Hash, Volume2 } from "lucide-react";
import { useCreateChannel } from "@/hooks/useServers";
import { CategoryWithChannels } from "@/types/api";
interface CreateChannelModalProps {
isOpen: boolean;
onClose: () => void;
categories: CategoryWithChannels[];
defaultCategoryId?: string;
}
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
isOpen,
onClose,
categories,
defaultCategoryId,
}) => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [type, setType] = useState<"text" | "voice">("text");
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
const createChannelMutation = useCreateChannel();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !categoryId) return;
try {
await createChannelMutation.mutateAsync({
name: name.trim(),
description: description.trim(),
type,
categoryId,
});
// Reset form
setName("");
setDescription("");
setType("text");
setCategoryId(defaultCategoryId || "");
onClose();
} catch (error) {
console.error("Failed to create channel:", error);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Create Channel</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Channel Type</Label>
<div className="flex gap-2">
<Button
type="button"
variant={type === "text" ? "default" : "outline"}
onClick={() => setType("text")}
className="flex-1"
>
<Hash className="h-4 w-4 mr-2" />
Text
</Button>
<Button
type="button"
variant={type === "voice" ? "default" : "outline"}
onClick={() => setType("voice")}
className="flex-1"
>
<Volume2 className="h-4 w-4 mr-2" />
Voice
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="channel-name">Channel Name</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="awesome-channel"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="channel-description">Description</Label>
<Textarea
id="channel-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's this channel about?"
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={categoryId} onValueChange={setCategoryId} required>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={
!name.trim() || !categoryId || createChannelMutation.isPending
}
>
{createChannelMutation.isPending
? "Creating..."
: "Create Channel"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,92 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCreateInstance } from "@/hooks/useServers";
interface CreateServerModalProps {
isOpen: boolean;
onClose: () => void;
}
export const CreateServerModal: React.FC<CreateServerModalProps> = ({
isOpen,
onClose,
}) => {
const [name, setName] = useState("");
const [icon, setIcon] = useState("");
const createInstanceMutation = useCreateInstance();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await createInstanceMutation.mutateAsync({
name: name.trim(),
icon: icon.trim() || undefined,
});
// Reset form
setName("");
setIcon("");
onClose();
} catch (error) {
console.error("Failed to create server:", error);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Create Server</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="server-name">Server Name</Label>
<Input
id="server-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Awesome Server"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-icon">Server Icon URL (optional)</Label>
<Input
id="server-icon"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="https://example.com/icon.png"
type="url"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={!name.trim() || createInstanceMutation.isPending}
>
{createInstanceMutation.isPending
? "Creating..."
: "Create Server"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,121 @@
import React, { useState, useRef, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Message } from "@/lib/api-client";
import { useEditMessage } from "@/hooks/useMessages";
interface EditMessageModalProps {
isOpen: boolean;
onClose: () => void;
message: Message;
channelId: string;
}
export const EditMessageModal: React.FC<EditMessageModalProps> = ({
isOpen,
onClose,
message,
channelId,
}) => {
const [content, setContent] = useState(message.text);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const editMessageMutation = useEditMessage();
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current && isOpen) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
textareaRef.current.focus();
}
}, [content, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (content.trim() && content.trim() !== message.text) {
try {
await editMessageMutation.mutateAsync({
messageId: message.id,
content: content.trim(),
channelId,
});
onClose();
} catch (error) {
console.error("Failed to edit message:", error);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
} else if (e.key === "Escape") {
onClose();
}
};
const handleCancel = () => {
setContent(message.text);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Edit Message</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="message-content">Message</Label>
<textarea
ref={textareaRef}
id="message-content"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
style={{
minHeight: "100px",
maxHeight: "300px",
}}
disabled={editMessageMutation.isPending}
required
/>
<p className="text-xs text-muted-foreground">
Press Enter to save Shift+Enter for new line Escape to cancel
</p>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={editMessageMutation.isPending}
>
Cancel
</Button>
<Button
type="submit"
disabled={
!content.trim() ||
content.trim() === message.text ||
editMessageMutation.isPending
}
>
{editMessageMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,101 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Copy, Edit, Trash2, Pin, Reply } from "lucide-react";
import { Message } from "@/lib/api-client";
interface MessageActionsModalProps {
isOpen: boolean;
onClose: () => void;
message: Message;
isOwnMessage: boolean;
canDelete: boolean; // For mods/admins
onEdit?: (messageId: string) => void;
onDelete?: (messageId: string) => void;
onReply?: (messageId: string) => void;
onPin?: (messageId: string) => void;
}
export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
isOpen,
onClose,
message,
isOwnMessage,
canDelete,
onEdit,
onDelete,
onReply,
onPin,
}) => {
const handleAction = (action: () => void) => {
action();
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[300px]">
<DialogHeader>
<DialogTitle>Message Actions</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => handleAction(() => onReply?.(message.id))}
>
<Reply className="h-4 w-4 mr-2" />
Reply
</Button>
<Button
variant="ghost"
className="w-full justify-start"
onClick={() =>
handleAction(() => navigator.clipboard.writeText(message.text))
}
>
<Copy className="h-4 w-4 mr-2" />
Copy Text
</Button>
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => handleAction(() => onPin?.(message.id))}
>
<Pin className="h-4 w-4 mr-2" />
Pin Message
</Button>
{isOwnMessage && (
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => handleAction(() => onEdit?.(message.id))}
>
<Edit className="h-4 w-4 mr-2" />
Edit Message
</Button>
)}
{(isOwnMessage || canDelete) && (
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => handleAction(() => onDelete?.(message.id))}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Message
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,155 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Pin, X, ExternalLink } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { formatDistanceToNow } from "date-fns";
import { Message } from "@/types/database";
import { usePinnedMessages, usePinMessage } from "@/hooks/useMessages";
interface PinnedMessagesModalProps {
isOpen: boolean;
onClose: () => void;
channelId: string;
channelName: string;
canManagePins: boolean;
}
export const PinnedMessagesModal: React.FC<PinnedMessagesModalProps> = ({
isOpen,
onClose,
channelId,
channelName,
canManagePins,
}) => {
const { data: pinnedMessages, isLoading } = usePinnedMessages(channelId);
const pinMessageMutation = usePinMessage();
const handleUnpin = async (messageId: string) => {
try {
await pinMessageMutation.mutateAsync({
messageId,
channelId,
pinned: false,
});
} catch (error) {
console.error("Failed to unpin message:", error);
}
};
const handleJumpToMessage = (messageId: string) => {
// TODO: Implement jumping to message in chat
console.log("Jumping to message:", messageId);
onClose();
};
if (isLoading) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] sm:max-h-[70vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pin className="h-5 w-5" />
Pinned Messages in #{channelName}
</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] sm:max-h-[70vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pin className="h-5 w-5" />
Pinned Messages in #{channelName}
</DialogTitle>
</DialogHeader>
{!pinnedMessages || pinnedMessages.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Pin className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No pinned messages yet</p>
<p className="text-sm">
Pin important messages to keep them accessible
</p>
</div>
) : (
<ScrollArea className="max-h-[50vh]">
<div className="space-y-4">
{pinnedMessages.map((message) => (
<div
key={message.id}
className="border border-border rounded-lg p-4 bg-concord-secondary/50"
>
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={message.user?.picture || undefined} />
<AvatarFallback className="text-xs">
{message.user?.userName?.slice(0, 2).toUpperCase() ||
"??"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">
{message.user?.nickName ||
message.user?.userName ||
"Unknown User"}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(message.createdAt), {
addSuffix: true,
})}
</span>
</div>
<div className="text-sm text-concord-primary leading-relaxed break-words">
{message.content}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleJumpToMessage(message.id)}
>
<ExternalLink className="h-4 w-4" />
</Button>
{canManagePins && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleUnpin(message.id)}
disabled={pinMessageMutation.isPending}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,262 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Crown, Shield, User, AlertTriangle } from "lucide-react";
import { User as UserType } from "@/types/database";
interface UserRoleModalProps {
isOpen: boolean;
onClose: () => void;
user: UserType;
instanceId: string;
currentUserRole: string;
canManageRoles: boolean;
}
export const UserRoleModal: React.FC<UserRoleModalProps> = ({
isOpen,
onClose,
user,
instanceId,
currentUserRole,
canManageRoles,
}) => {
const userInstanceRole =
user.roles.find((r) => r.instanceId === instanceId)?.role || "member";
const [selectedRole, setSelectedRole] = useState(userInstanceRole);
const [isUpdating, setIsUpdating] = useState(false);
const roleOptions = [
{
value: "member",
label: "Member",
icon: User,
description: "Standard server access",
},
{
value: "mod",
label: "Moderator",
icon: Shield,
description: "Can moderate channels and manage messages",
},
{
value: "admin",
label: "Admin",
icon: Crown,
description: "Full server management access",
},
];
const getRoleColor = (role: string) => {
switch (role) {
case "admin":
return "destructive";
case "mod":
return "secondary";
default:
return "outline";
}
};
const handleUpdateRole = async () => {
if (selectedRole === userInstanceRole || !canManageRoles) return;
setIsUpdating(true);
try {
// TODO: Implement actual role update API call
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log(
`Updating ${user.username}'s role to ${selectedRole} in instance ${instanceId}`,
);
// await userClient.updateRole({
// userId: user.id,
// instanceId,
// role: selectedRole,
// requestingUserId: currentUser.id,
// token: authStore.token
// });
onClose();
} catch (error) {
console.error("Failed to update role:", error);
} finally {
setIsUpdating(false);
}
};
const handleKickUser = async () => {
if (!canManageRoles) return;
if (
confirm(
`Are you sure you want to kick ${user.nickname || user.username} from this server?`,
)
) {
try {
// TODO: Implement kick user API call
console.log(`Kicking ${user.username} from instance ${instanceId}`);
onClose();
} catch (error) {
console.error("Failed to kick user:", error);
}
}
};
const canModifyUser =
canManageRoles && !user.admin && userInstanceRole !== "admin";
const isOwnProfile = false; // TODO: Check if this is the current user's profile
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Manage User</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* User Info */}
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarImage src={user.picture || undefined} />
<AvatarFallback>
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">
{user.nickname || user.username}
</span>
{user.admin && <Crown className="h-4 w-4 text-yellow-500" />}
</div>
<div className="flex items-center gap-2">
<Badge variant={getRoleColor(userInstanceRole)}>
{userInstanceRole}
</Badge>
<div
className={`w-2 h-2 rounded-full ${
user.status === "online"
? "bg-green-500"
: user.status === "away"
? "bg-yellow-500"
: user.status === "busy"
? "bg-red-500"
: "bg-gray-500"
}`}
/>
<span className="text-sm text-muted-foreground capitalize">
{user.status}
</span>
</div>
</div>
</div>
{user.bio && (
<div className="text-sm text-muted-foreground">{user.bio}</div>
)}
<Separator />
{/* Role Management */}
{canModifyUser && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Server Role</Label>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roleOptions.map((role) => {
const Icon = role.icon;
return (
<SelectItem key={role.value} value={role.value}>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4" />
<div>
<div className="font-medium">{role.label}</div>
<div className="text-xs text-muted-foreground">
{role.description}
</div>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button
onClick={handleUpdateRole}
disabled={selectedRole === userInstanceRole || isUpdating}
className="flex-1"
>
{isUpdating ? "Updating..." : "Update Role"}
</Button>
<Button
variant="outline"
onClick={onClose}
disabled={isUpdating}
>
Cancel
</Button>
</div>
<Separator />
{/* Danger Zone */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" />
<Label className="text-destructive">Danger Zone</Label>
</div>
<Button
variant="destructive"
onClick={handleKickUser}
className="w-full"
disabled={isUpdating}
>
Kick from Server
</Button>
</div>
</div>
)}
{/* View Only Mode */}
{!canModifyUser && (
<div className="text-center py-4">
<p className="text-sm text-muted-foreground">
{user.admin
? "System administrators cannot be modified"
: "You don't have permission to modify this user"}
</p>
<Button variant="outline" onClick={onClose} className="mt-4">
Close
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,83 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Settings, LogOut } from "lucide-react";
import { useLogout } from "@/hooks/useAuth";
import { useUiStore } from "@/stores/uiStore";
interface UserStatusModalProps {
isOpen: boolean;
onClose: () => void;
currentStatus: string;
onStatusChange: (status: string) => void;
}
export const UserStatusModal: React.FC<UserStatusModalProps> = ({
isOpen,
onClose,
currentStatus,
onStatusChange,
}) => {
const { mutate: logout } = useLogout();
const { openUserSettings } = useUiStore();
const statusOptions = [
{ value: "online", label: "Online", color: "bg-status-online" },
{ value: "away", label: "Away", color: "bg-status-away" },
{ value: "busy", label: "Do Not Disturb", color: "bg-status-busy" },
{ value: "offline", label: "Invisible", color: "bg-status-offline" },
];
const handleAction = (action: () => void) => {
action();
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[250px]">
<DialogHeader>
<DialogTitle>Status & Settings</DialogTitle>
</DialogHeader>
<div className="space-y-2">
{statusOptions.map((status) => (
<Button
key={status.value}
variant={currentStatus === status.value ? "secondary" : "ghost"}
className="w-full justify-start"
onClick={() => handleAction(() => onStatusChange(status.value))}
>
<div className={`w-3 h-3 rounded-full ${status.color} mr-2`} />
{status.label}
</Button>
))}
<div className="pt-2 border-t">
<Button
variant="ghost"
className="w-full justify-start"
onClick={() => handleAction(() => openUserSettings())}
>
<Settings className="h-4 w-4 mr-2" />
User Settings
</Button>
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => handleAction(() => logout())}
>
<LogOut className="h-4 w-4 mr-2" />
Log Out
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,6 +1,30 @@
import React from "react"; import React, { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Instance } from "@/types/database"; import { Instance } from "@/types/database";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
} from "../ui/dialog";
import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog";
import { Label } from "../ui/label";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import { Category } from "@/types";
import { useUiStore } from "@/stores/uiStore";
import {
useCreateCategory,
useCreateChannel,
useCreateInstance,
} from "@/hooks/useServers";
import {
Select,
SelectContent,
SelectTrigger,
SelectItem,
SelectValue,
} from "../ui/select";
interface ServerIconProps { interface ServerIconProps {
server: Instance; server: Instance;
@@ -44,7 +68,7 @@ const ServerIcon: React.FC<ServerIconProps> = ({
<img <img
src={server.icon} src={server.icon}
alt={server.name} alt={server.name}
className="w-full h-full object-cover rounded-inherit" className={`w-full h-full object-cover ${isActive ? "rounded-xl" : "rounded-2xl"}`}
/> />
) : ( ) : (
<span className="font-semibold text-sm"> <span className="font-semibold text-sm">
@@ -56,4 +80,308 @@ const ServerIcon: React.FC<ServerIconProps> = ({
); );
}; };
// Create Server Modal
export const CreateServerModal: React.FC = () => {
const { showCreateServer, closeCreateServer } = useUiStore();
const { mutate: createInstance, isPending } = useCreateInstance();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
createInstance(
{
name: name.trim(),
icon: icon.trim() || undefined,
description: description.trim() || undefined,
},
{
onSuccess: () => {
setName("");
setDescription("");
setIcon("");
closeCreateServer();
},
onError: (error) => {
console.error("Failed to create server:", error);
},
},
);
}
};
const handleClose = () => {
setName("");
setDescription("");
setIcon("");
closeCreateServer();
};
return (
<Dialog open={showCreateServer} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create a Server</DialogTitle>
<DialogDescription>
Create a new server to chat with friends and communities.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="server-name">Server Name</Label>
<Input
id="server-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter server name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-description">Description (Optional)</Label>
<Textarea
id="server-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's this server about?"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-icon">Server Icon URL (Optional)</Label>
<Input
id="server-icon"
type="url"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="https://example.com/icon.png"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !name.trim()}>
{isPending ? "Creating..." : "Create Server"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
// Create Channel Modal
export const CreateChannelModal: React.FC = () => {
const { showCreateChannel, closeCreateChannel } = useUiStore();
const { mutate: createChannel, isPending } = useCreateChannel();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [type, setType] = useState<"text" | "voice">("text");
const [categoryId, setCategoryId] = useState("");
// You'd need to get categories for the current instance
// This is a simplified version
const categories: Category[] = []; // Get from context or props
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim() && categoryId) {
createChannel(
{
name: name.trim(),
description: description.trim(),
type,
categoryId,
},
{
onSuccess: () => {
setName("");
setDescription("");
setType("text");
setCategoryId("");
closeCreateChannel();
},
onError: (error) => {
console.error("Failed to create channel:", error);
},
},
);
}
};
const handleClose = () => {
setName("");
setDescription("");
setType("text");
setCategoryId("");
closeCreateChannel();
};
return (
<Dialog open={showCreateChannel} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create a Channel</DialogTitle>
<DialogDescription>
Create a new text or voice channel in this server.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="channel-type">Channel Type</Label>
<Select
value={type}
onValueChange={(value: "text" | "voice") => setType(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select channel type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text Channel</SelectItem>
<SelectItem value="voice">Voice Channel</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="channel-category">Category</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="channel-name">Channel Name</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter channel name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="channel-description">Description (Optional)</Label>
<Textarea
id="channel-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's this channel for?"
rows={3}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !name.trim()}>
{isPending ? "Creating..." : "Create Channel"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
// Create Category Modal
export const CreateCategoryModal: React.FC = () => {
const [open, setOpen] = useState(false);
const { mutate: createCategory, isPending } = useCreateCategory();
const [name, setName] = useState("");
const [instanceId, setInstanceId] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim() && instanceId) {
createCategory(
{
name: name.trim(),
instanceId,
position: 0,
},
{
onSuccess: () => {
setName("");
setInstanceId("");
setOpen(false);
},
onError: (error) => {
console.error("Failed to create category:", error);
},
},
);
}
};
const handleClose = () => {
setName("");
setInstanceId("");
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create a Category</DialogTitle>
<DialogDescription>
Create a new category to organize your channels.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="category-name">Category Name</Label>
<Input
id="category-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter category name"
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !name.trim()}>
{isPending ? "Creating..." : "Create Category"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export const AdminModals: React.FC = () => {
return (
<>
<CreateServerModal />
<CreateChannelModal />
<CreateCategoryModal />
</>
);
};
export default ServerIcon; export default ServerIcon;

View File

@@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@@ -15,7 +15,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
@@ -26,7 +26,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) );
} }
function DropdownMenuContent({ function DropdownMenuContent({
@@ -41,12 +41,12 @@ function DropdownMenuContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@@ -54,7 +54,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
@@ -63,8 +63,8 @@ function DropdownMenuItem({
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
@@ -73,11 +73,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@@ -146,7 +146,7 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@@ -154,11 +154,11 @@ function DropdownMenuLabel({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@@ -171,7 +171,7 @@ function DropdownMenuSeparator({
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({ function DropdownMenuShortcut({
@@ -183,17 +183,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: 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({
@@ -202,7 +202,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -210,14 +210,14 @@ function DropdownMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@@ -229,11 +229,11 @@ function DropdownMenuSubContent({
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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -252,4 +252,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

View File

@@ -0,0 +1,213 @@
// src/hooks/useAuth.ts - Fixed with proper types
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/authStore";
import {
authClient,
LoginCredentials,
RegisterData,
AuthResponse,
} from "@/lib/auth-client";
import { BackendUser } from "@/lib/api-client";
import { Role, UserStatus } from "@/types/database";
// Frontend User type
interface FrontendUser {
id: string;
username: string;
nickname?: string | null;
bio?: string | null;
picture?: string | null;
banner?: string | null;
hashPassword: string;
admin: boolean;
status: UserStatus;
createdAt: string;
updatedAt: string;
roles: Role[];
}
// Transform backend user to frontend user format
function transformBackendUser(backendUser: BackendUser): FrontendUser {
return {
id: backendUser.id,
username: backendUser.userName,
nickname: backendUser.nickName,
bio: backendUser.bio,
picture: backendUser.picture,
banner: backendUser.banner,
hashPassword: "", // Don't store password
admin: backendUser.admin,
status: transformStatusToFrontend(backendUser.status),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: backendUser.role.map((r) => ({
instanceId: r.instanceId || "",
role: r.role || "member",
})) as Role[],
};
}
// Transform status from backend to frontend format
function transformStatusToFrontend(
backendStatus: "online" | "offline" | "dnd" | "idle" | "invis",
): UserStatus {
switch (backendStatus) {
case "dnd":
return "busy";
case "idle":
return "away";
case "invis":
return "offline";
default:
return "online";
}
}
// Transform status from frontend to backend format
export function transformStatusToBackend(
frontendStatus: UserStatus,
): "online" | "offline" | "dnd" | "idle" | "invis" {
switch (frontendStatus) {
case "busy":
return "dnd";
case "away":
return "idle";
case "offline":
return "invis";
default:
return "online";
}
}
// Hook for login
export const useLogin = () => {
const { setAuth, setLoading } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
credentials: LoginCredentials,
): Promise<AuthResponse> => {
setLoading(true);
return authClient.login(credentials);
},
onSuccess: (data: AuthResponse) => {
const frontendUser = transformBackendUser(data.user);
setAuth(frontendUser, data.token, data.token); // Use token as refresh token for now
queryClient.clear();
},
onError: (error: Error) => {
console.error("Login failed:", error);
setLoading(false);
},
onSettled: () => {
setLoading(false);
},
});
};
// Hook for registration (requires admin)
export const useRegister = () => {
const { setAuth, setLoading, user, token } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => {
setLoading(true);
if (!user || !token || !user.admin) {
throw new Error("Admin privileges required for user creation");
}
return authClient.register(userData, { id: user.id, token });
},
onSuccess: (data: AuthResponse) => {
const frontendUser = transformBackendUser(data.user);
setAuth(frontendUser, data.token, data.token);
queryClient.clear();
},
onError: (error: Error) => {
console.error("Registration failed:", error);
setLoading(false);
},
onSettled: () => {
setLoading(false);
},
});
};
// Hook for logout
export const useLogout = () => {
const { logout, user } = useAuthStore();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (): Promise<void> => {
if (user) {
try {
await authClient.logout(user.id);
} catch (error) {
console.warn("Logout endpoint failed:", error);
}
}
},
onSuccess: () => {
logout();
queryClient.clear();
},
onError: (error: Error) => {
console.error("Logout failed:", error);
// Still logout locally even if server request fails
logout();
queryClient.clear();
},
});
};
// Hook for token validation
export const useValidateToken = () => {
const { token, user, setAuth, logout } = useAuthStore();
return useMutation({
mutationFn: async (): Promise<{ valid: boolean; user?: BackendUser }> => {
if (!token || !user) {
throw new Error("No token to validate");
}
return authClient.validateToken(token, user.id);
},
onSuccess: (data) => {
if (!data.valid) {
logout();
} else if (data.user) {
const frontendUser = transformBackendUser(data.user);
setAuth(frontendUser, token!, useAuthStore.getState().refreshToken!);
}
},
onError: (error: Error) => {
console.error("Token validation failed:", error);
logout();
},
});
};
// Hook for token refresh
export const useRefreshToken = () => {
const { refreshToken, user, setAuth, logout } = useAuthStore();
return useMutation({
mutationFn: async (): Promise<AuthResponse> => {
if (!refreshToken || !user) {
throw new Error("No refresh token available");
}
return authClient.refreshToken(refreshToken, user.id);
},
onSuccess: (data: AuthResponse) => {
const frontendUser = transformBackendUser(data.user);
setAuth(frontendUser, data.token, data.token);
},
onError: (error: Error) => {
console.error("Token refresh failed:", error);
logout();
},
});
};

View File

@@ -1,86 +0,0 @@
import { Message } from "@/types/database";
import { useQuery } from "@tanstack/react-query";
// Sample messages data
export const SAMPLE_MESSAGES = [
{
id: "1",
content: "Hey everyone! Just finished the new theme system. Check it out!",
channelId: "1", // general channel
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{
id: "2",
content:
"Looking great! The dark mode especially feels much better on the eyes 👀",
channelId: "1",
userId: "2",
edited: false,
createdAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
},
{
id: "3",
content: "Can we add a **high contrast mode** for accessibility?",
channelId: "1",
userId: "3",
edited: false,
createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
},
{
id: "4",
content:
"```typescript\nconst theme = {\n primary: 'oklch(0.6 0.2 240)',\n secondary: 'oklch(0.8 0.1 60)'\n};\n```\nHere's how the new color system works!",
channelId: "1",
userId: "3",
edited: false,
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
},
{
id: "5",
content:
"Perfect timing! I was just about to ask about the color format. _OKLCH_ is so much better than HSL for this.",
channelId: "1",
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
},
// Messages for random channel
{
id: "6",
content: "Anyone up for a game tonight?",
channelId: "2", // random channel
userId: "2",
edited: false,
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
},
{
id: "7",
content: "I'm in! What are we playing?",
channelId: "2",
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
},
];
export const useChannelMessages = (channelId?: string) => {
return useQuery({
queryKey: ["messages", channelId],
queryFn: async (): Promise<Message[]> => {
if (!channelId) return [];
await new Promise((resolve) => setTimeout(resolve, 100));
return SAMPLE_MESSAGES.filter((msg) => msg.channelId === channelId);
},
enabled: !!channelId,
staleTime: 1000 * 60 * 1,
});
};

View File

@@ -0,0 +1,313 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient, Message } from "@/lib/api-client";
import { useAuthStore } from "@/stores/authStore";
// Hook for getting messages in a channel with pagination
export const useChannelMessages = (channelId?: string, limit = 50) => {
return useQuery({
queryKey: ["messages", channelId, limit],
queryFn: async (): Promise<Message[]> => {
if (!channelId) return [];
try {
const date = new Date();
const messages = await apiClient.getMessages({
date: date.toISOString(),
channelId: channelId,
});
return messages || [];
} catch (error) {
console.error("Failed to fetch messages:", error);
return [];
}
},
enabled: !!channelId,
staleTime: 1000 * 60 * 1,
refetchInterval: 1000 * 30,
});
};
// Hook for getting older messages (pagination)
export const useChannelMessagesPaginated = (
channelId?: string,
beforeDate?: Date,
limit = 50,
) => {
return useQuery({
queryKey: [
"messages",
channelId,
"paginated",
beforeDate?.toISOString(),
limit,
],
queryFn: async (): Promise<Message[]> => {
if (!channelId || !beforeDate) return [];
try {
const messages = await apiClient.getMessages({
date: beforeDate.toISOString(),
channelId: channelId,
});
return messages || [];
} catch (error) {
console.error("Failed to fetch paginated messages:", error);
return [];
}
},
enabled: !!channelId && !!beforeDate,
staleTime: 1000 * 60 * 5,
});
};
// Hook for sending messages
export const useSendMessage = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
channelId: string;
content: string;
repliedMessageId?: string | null;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
const requestData = {
channelId: data.channelId,
userId: user.id,
content: data.content,
token: token,
repliedMessageId: data.repliedMessageId,
};
try {
const message = await apiClient.sendMessage(requestData);
return message;
} catch (error) {
console.error("Failed to send message:", error);
throw new Error("Failed to send message");
}
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["messages", variables.channelId],
});
},
onError: (error) => {
console.error("Send message failed:", error);
},
});
};
// Hook for deleting messages
export const useDeleteMessage = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: { messageId: string; channelId: string }) => {
if (!user || !token) {
throw new Error("Authentication required");
}
// TODO: Replace with actual API call when available
return { success: true, messageId: data.messageId };
},
onSuccess: (result, variables) => {
// Update the cache to mark message as deleted
queryClient.setQueryData(
["messages", variables.channelId],
(oldData: Message[] | undefined) => {
if (!oldData) return oldData;
return oldData.map((msg) =>
msg.id === result.messageId
? { ...msg, content: "[Message deleted]", deleted: true }
: msg,
);
},
);
},
onError: (error) => {
console.error("Delete message failed:", error);
},
});
};
// Hook for editing messages
export const useEditMessage = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
messageId: string;
content: string;
channelId: string;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
// TODO: Replace with actual API call when available
console.log(
"Editing message:",
data.messageId,
"New content:",
data.content,
);
return {
success: true,
messageId: data.messageId,
content: data.content,
edited: true,
};
},
onSuccess: (result, variables) => {
// Update the cache with edited message
queryClient.setQueryData(
["messages", variables.channelId],
(oldData: Message[] | undefined) => {
if (!oldData) return oldData;
return oldData.map((msg) =>
msg.id === result.messageId
? { ...msg, content: result.content, edited: result.edited }
: msg,
);
},
);
},
onError: (error) => {
console.error("Edit message failed:", error);
},
});
};
// Hook for pinning/unpinning messages
export const usePinMessage = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
messageId: string;
channelId: string;
pinned: boolean;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
// TODO: Replace with actual API call when available
console.log(
`${data.pinned ? "Pinning" : "Unpinning"} message:`,
data.messageId,
);
return {
success: true,
messageId: data.messageId,
pinned: data.pinned,
};
},
onSuccess: (result, variables) => {
// Update the cache with pinned status
queryClient.setQueryData(
["messages", variables.channelId],
(oldData: Message[] | undefined) => {
if (!oldData) return oldData;
return oldData.map((msg) =>
msg.id === result.messageId
? { ...msg, pinned: result.pinned }
: msg,
);
},
);
// Also invalidate pinned messages query if it exists
queryClient.invalidateQueries({
queryKey: ["pinned-messages", variables.channelId],
});
},
onError: (error) => {
console.error("Pin message failed:", error);
},
});
};
// Hook for getting pinned messages
export const usePinnedMessages = (channelId?: string) => {
return useQuery({
queryKey: ["pinned-messages", channelId],
queryFn: async (): Promise<Message[]> => {
if (!channelId) return [];
try {
// TODO: Replace with actual API call when available
// For now, return empty array
return [];
} catch (error) {
console.error("Failed to fetch pinned messages:", error);
return [];
}
},
enabled: !!channelId,
staleTime: 1000 * 60 * 5,
});
};
// Hook for loading more messages (infinite scroll)
export const useLoadMoreMessages = (channelId?: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { beforeDate: Date }) => {
if (!channelId) return [];
try {
const messages = await apiClient.getMessages({
date: data.beforeDate.toISOString(),
channelId: channelId,
});
return messages || [];
} catch (error) {
console.error("Failed to load more messages:", error);
return [];
}
},
onSuccess: (newMessages) => {
if (newMessages.length > 0) {
// Prepend new messages to existing messages
queryClient.setQueryData(
["messages", channelId],
(oldData: Message[] | undefined) => {
if (!oldData) return newMessages;
// Remove duplicates and sort by creation date
const combined = [...newMessages, ...oldData];
const unique = combined.filter(
(msg, index, arr) =>
arr.findIndex((m) => m.id === msg.id) === index,
);
return unique.sort(
(a, b) =>
new Date(a.createdAt).getTime() -
new Date(b.createdAt).getTime(),
);
},
);
}
},
});
};

View File

@@ -1,334 +1,117 @@
import { useQuery } from "@tanstack/react-query"; // src/hooks/useServers.ts - Fixed with proper types
import { Instance, User } from "@/types/database"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { InstanceWithDetails } from "@/types/api"; import {
import { CategoryWithChannels } from "@/types/api"; apiClient,
Instance,
Category,
Channel,
BackendUser,
} from "@/lib/api-client";
import { useAuthStore } from "@/stores/authStore";
// Sample users data with proper Role structure // Extended types with relations for frontend use
export const SAMPLE_USERS: User[] = [ export interface CategoryWithChannels extends Category {
{ channels: Channel[];
id: "1", }
username: "alice_dev",
nickname: "Alice", export interface InstanceWithDetails extends Instance {
bio: "Frontend developer who loves React", categories: CategoryWithChannels[];
picture: null, }
banner: null,
status: "online" as const, // Transform backend user to frontend user format for compatibility
function transformBackendUserToFrontend(backendUser: BackendUser) {
return {
id: backendUser.id,
username: backendUser.userName,
nickname: backendUser.nickName,
bio: backendUser.bio,
picture: backendUser.picture,
banner: backendUser.banner,
hashPassword: "", hashPassword: "",
admin: false, admin: backendUser.admin,
status:
backendUser.status === "dnd"
? "busy"
: backendUser.status === "idle"
? "away"
: backendUser.status === "invis"
? "offline"
: backendUser.status,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
roles: [], // Will be populated per instance roles: backendUser.role.map((r) => ({
}, instanceId: r.instanceId || "",
{ role: r.role || "member",
id: "2", })),
username: "bob_designer", };
nickname: "Bob", }
bio: "UI/UX Designer & Coffee Enthusiast",
picture:
"https://media.istockphoto.com/id/1682296067/photo/happy-studio-portrait-or-professional-man-real-estate-agent-or-asian-businessman-smile-for.jpg?s=612x612&w=0&k=20&c=9zbG2-9fl741fbTWw5fNgcEEe4ll-JegrGlQQ6m54rg=",
banner: null,
status: "away" as const,
hashPassword: "",
admin: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [],
},
{
id: "3",
username: "charlie_backend",
nickname: "Charlie",
bio: "Backend wizard, scaling systems since 2018",
picture: null,
banner: null,
status: "busy" as const,
hashPassword: "",
admin: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [],
},
{
id: "current",
username: "you",
nickname: "You",
bio: "That's you!",
picture: null,
banner: null,
status: "online" as const,
hashPassword: "",
admin: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [],
},
];
// Sample servers data // Hook for getting all servers/instances
const SAMPLE_SERVERS: Instance[] = [
{
id: "1",
name: "Dev Team",
icon: null,
description: "Our development team server",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "2",
name: "Gaming Squad",
icon: null,
description: "Gaming and fun times",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "3",
name: "Book Club",
icon: null,
description: "Monthly book discussions",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
// Sample messages data
export const SAMPLE_MESSAGES = [
{
id: "1",
content: "Hey everyone! Just finished the new theme system. Check it out!",
channelId: "1", // general channel
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{
id: "2",
content:
"Looking great! The dark mode especially feels much better on the eyes 👀",
channelId: "1",
userId: "2",
edited: false,
createdAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
},
{
id: "3",
content: "Can we add a **high contrast mode** for accessibility?",
channelId: "1",
userId: "3",
edited: false,
createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
},
{
id: "4",
content:
"```typescript\nconst theme = {\n primary: 'oklch(0.6 0.2 240)',\n secondary: 'oklch(0.8 0.1 60)'\n};\n```\nHere's how the new color system works!",
channelId: "1",
userId: "3",
edited: false,
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
},
{
id: "5",
content:
"Perfect timing! I was just about to ask about the color format. _OKLCH_ is so much better than HSL for this.",
channelId: "1",
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
},
// Messages for random channel
{
id: "6",
content: "Anyone up for a game tonight?",
channelId: "2", // random channel
userId: "2",
edited: false,
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
},
{
id: "7",
content: "I'm in! What are we playing?",
channelId: "2",
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
},
];
// Sample categories with channels
const createSampleCategories = (instanceId: string): CategoryWithChannels[] => [
{
id: "1",
name: "Text Channels",
instanceId: instanceId,
position: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
channels: [
{
id: "1",
name: "general",
type: "text",
categoryId: "1",
instanceId: instanceId,
position: 0,
description: "General discussion about development and projects",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "2",
name: "random",
type: "text",
categoryId: "1",
instanceId: instanceId,
position: 1,
description: "Random chat and off-topic discussions",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "3",
name: "announcements",
type: "text",
categoryId: "1",
instanceId: instanceId,
position: 2,
description: "Important announcements and updates",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
{
id: "2",
name: "Voice Channels",
instanceId: instanceId,
position: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
channels: [
{
id: "4",
name: "General",
type: "voice",
categoryId: "2",
instanceId: instanceId,
position: 0,
description: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "5",
name: "Focus Room",
type: "voice",
categoryId: "2",
instanceId: instanceId,
position: 1,
description: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
{
id: "3",
name: "Project Channels",
instanceId: instanceId,
position: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
channels: [
{
id: "6",
name: "frontend",
type: "text",
categoryId: "3",
instanceId: instanceId,
position: 0,
description: "Frontend development discussions",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "7",
name: "backend",
type: "text",
categoryId: "3",
instanceId: instanceId,
position: 1,
description: "Backend and API development",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
];
// Placeholder hook for channels by instance
export const useChannels = (instanceId?: string) => {
return useQuery({
queryKey: ["channels", instanceId],
queryFn: async (): Promise<CategoryWithChannels[]> => {
if (!instanceId) return [];
return createSampleCategories(instanceId);
},
enabled: !!instanceId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
};
// Hook for getting messages in a channel
export const useChannelMessages = (channelId?: string) => {
return useQuery({
queryKey: ["messages", channelId],
queryFn: async () => {
if (!channelId) return [];
// Return messages for this channel
return SAMPLE_MESSAGES.filter((msg) => msg.channelId === channelId);
},
enabled: !!channelId,
staleTime: 1000 * 60 * 1, // 1 minute (messages are more dynamic)
});
};
// Placeholder hook for servers/instances
export const useServers = () => { export const useServers = () => {
return useQuery({ return useQuery({
queryKey: ["servers"], queryKey: ["servers"],
queryFn: async (): Promise<Instance[]> => { queryFn: async (): Promise<Instance[]> => {
await new Promise((resolve) => setTimeout(resolve, 100)); try {
return SAMPLE_SERVERS; const instances = await apiClient.getInstances();
return instances;
} catch (error) {
console.error("Failed to fetch servers:", error);
throw new Error("Failed to fetch servers");
}
}, },
staleTime: 1000 * 60 * 5, staleTime: 1000 * 60 * 5,
}); });
}; };
// Hook for getting detailed instance info with categories and channels
export const useInstanceDetails = (instanceId?: string) => { export const useInstanceDetails = (instanceId?: string) => {
return useQuery({ return useQuery({
queryKey: ["instance", instanceId], queryKey: ["instance", instanceId],
queryFn: async (): Promise<InstanceWithDetails | null> => { queryFn: async (): Promise<InstanceWithDetails | null> => {
if (!instanceId) return null; if (!instanceId) return null;
await new Promise((resolve) => setTimeout(resolve, 100));
const server = SAMPLE_SERVERS.find((s) => s.id === instanceId); try {
if (!server) return null; // Get instance basic info
const instances = await apiClient.getInstances();
const instance = instances.find((s) => s.id === instanceId);
if (!instance) return null;
// Get categories for this instance
const categories = await apiClient.getCategoriesByInstance(instanceId);
// For each category, get its channels
const categoriesWithChannels: CategoryWithChannels[] =
await Promise.all(
categories.map(async (category): Promise<CategoryWithChannels> => {
try {
const channels = await apiClient.getChannelsByCategory(
category.id,
);
return {
...category,
channels: channels || [],
};
} catch (error) {
console.warn(
`Failed to fetch channels for category ${category.id}:`,
error,
);
return {
...category,
channels: [],
};
}
}),
);
return { return {
...server, ...instance,
categories: createSampleCategories(instanceId), categories: categoriesWithChannels,
}; };
} catch (error) {
console.error("Failed to fetch instance details:", error);
throw new Error("Failed to fetch instance details");
}
}, },
enabled: !!instanceId, enabled: !!instanceId,
staleTime: 1000 * 60 * 5, staleTime: 1000 * 60 * 5,
@@ -339,21 +122,149 @@ export const useInstanceDetails = (instanceId?: string) => {
export const useInstanceMembers = (instanceId?: string) => { export const useInstanceMembers = (instanceId?: string) => {
return useQuery({ return useQuery({
queryKey: ["instance", instanceId, "members"], queryKey: ["instance", instanceId, "members"],
queryFn: async (): Promise<User[]> => { queryFn: async () => {
if (!instanceId) return []; if (!instanceId) return [];
await new Promise((resolve) => setTimeout(resolve, 100));
return SAMPLE_USERS.map((user, index) => ({ try {
...user, const backendUsers = await apiClient.getUsersByInstance(instanceId);
roles: [ // Transform backend users to frontend format for compatibility
{ return backendUsers.map(transformBackendUserToFrontend);
instanceId: instanceId, } catch (error) {
role: index === 0 ? "admin" : index === 1 ? "mod" : "member", console.error("Failed to fetch instance members:", error);
}, throw new Error("Failed to fetch instance members");
], }
}));
}, },
enabled: !!instanceId, enabled: !!instanceId,
staleTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 2,
}); });
}; };
// Hook for creating a new server/instance
export const useCreateInstance = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: { name: string; icon?: string }) => {
if (!user || !token) {
throw new Error("Authentication required");
}
const requestData = {
...data,
requestingUserId: user.id,
requestingUserToken: token,
};
try {
const instance = await apiClient.createInstance(requestData);
return instance;
} catch (error) {
console.error("Failed to create instance:", error);
throw new Error("Failed to create instance");
}
},
onSuccess: () => {
// Invalidate servers list to refetch
queryClient.invalidateQueries({ queryKey: ["servers"] });
},
});
};
// Hook for creating a new category
export const useCreateCategory = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
name: string;
instanceId?: string;
position: number;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
const requestData = {
...data,
admin: user.admin,
requestingUserId: user.id,
requestingUserToken: token,
};
try {
const category = await apiClient.createCategory(requestData);
return category;
} catch (error) {
console.error("Failed to create category:", error);
throw new Error("Failed to create category");
}
},
onSuccess: (_, variables) => {
// Invalidate instance details to refetch categories
if (variables.instanceId) {
queryClient.invalidateQueries({
queryKey: ["instance", variables.instanceId],
});
}
},
});
};
// Hook for creating a new channel
export const useCreateChannel = () => {
const queryClient = useQueryClient();
const { user, token } = useAuthStore();
return useMutation({
mutationFn: async (data: {
type: "text" | "voice";
name: string;
description: string;
categoryId?: string;
}) => {
if (!user || !token) {
throw new Error("Authentication required");
}
const requestData = {
...data,
admin: user.admin,
requestingUserId: user.id,
requestingUserToken: token,
};
try {
const channel = await apiClient.createChannel(requestData);
return channel;
} catch (error) {
console.error("Failed to create channel:", error);
throw new Error("Failed to create channel");
}
},
onSuccess: (_, variables) => {
// Invalidate related queries
if (variables.categoryId) {
// Find the instance this category belongs to and invalidate it
queryClient.invalidateQueries({
queryKey: ["instance"],
});
}
},
});
};
// Placeholder hook for channels by instance (for backward compatibility)
export const useChannels = (instanceId?: string) => {
const { data: instance } = useInstanceDetails(instanceId);
return useQuery({
queryKey: ["channels", instanceId],
queryFn: async (): Promise<CategoryWithChannels[]> => {
return instance?.categories || [];
},
enabled: !!instanceId && !!instance,
staleTime: 1000 * 60 * 5,
});
};

View File

@@ -0,0 +1,351 @@
import { QueryClient } from "@tanstack/react-query";
// Base API configuration
export const API_BASE_URL =
import.meta.env.VITE_API_URL || "http://localhost:3000";
// Enhanced QueryClient with error handling
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
refetchOnWindowFocus: true,
retry: (failureCount, error: any) => {
// Don't retry on auth errors
if (error?.status === 401 || error?.status === 403) return false;
return failureCount < 3;
},
},
mutations: {
retry: (failureCount, error: any) => {
if (error?.status === 401 || error?.status === 403) return false;
return failureCount < 2;
},
},
},
});
// API Response types based on your backend
export interface ApiResponse<T> {
success?: boolean;
data?: T;
error?: string;
}
// Specific response types for your backend
export interface Instance {
id: string;
name: string;
icon?: string | null;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface Category {
id: string;
name: string;
instanceId: string;
position: number;
createdAt: string;
updatedAt: string;
}
export interface Channel {
id: string;
name: string;
type: "text" | "voice";
categoryId: string;
instanceId: string;
position: number;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface BackendUser {
id: string;
userName: string;
nickName: string | null;
bio: string | null;
picture: string | null;
banner: string | null;
admin: boolean;
status: "online" | "offline" | "dnd" | "idle" | "invis";
role: Array<{
userId: string;
instanceId: string;
role?: string;
}>;
}
export interface Message {
id: string;
text: string;
channelId: string;
userId: string;
edited: boolean;
createdAt: string;
deleted: boolean;
updatedAt: string;
replyToId?: string | null;
user?: BackendUser;
}
// Enhanced fetch wrapper with auth and error handling
export class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl;
}
private async getAuthHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
return headers;
}
private async request<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers = await this.getAuthHeaders();
const config: RequestInit = {
...options,
headers: {
...headers,
...options.headers,
},
};
try {
const response = await fetch(url, config);
if (!response.ok) {
if (response.status === 401) {
// Handle auth error - logout user
const authStore = await import("@/stores/authStore");
authStore.useAuthStore.getState().logout();
throw new Error("Authentication required");
}
const errorData = await response
.json()
.catch(() => ({ error: "Unknown error" }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
const data = await response.json();
return data as T;
} catch (error) {
console.error(`API request failed: ${endpoint}`, error);
throw error;
}
}
// Instance/Server methods
async getInstances(): Promise<Instance[]> {
const response = await this.request<
{ success: boolean; data: Instance[] } | Instance[]
>("/api/instance");
// Handle both wrapped and direct responses
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
}
async createInstance(data: {
name: string;
icon?: string;
requestingUserId: string;
requestingUserToken: string;
}): Promise<Instance> {
return this.request<Instance>("/api/instance", {
method: "POST",
body: JSON.stringify(data),
});
}
// Categories methods
async getCategoriesByInstance(instanceId: string): Promise<Category[]> {
try {
const response = await this.request<Category[] | { data: Category[] }>(
`/api/category/instance/${instanceId}`,
);
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
} catch (error) {
console.warn(
`Categories endpoint not available for instance ${instanceId}`,
error,
);
return [];
}
}
async createCategory(data: {
name: string;
position: number;
instanceId?: string;
admin: boolean;
requestingUserId: string;
requestingUserToken: string;
}): Promise<Category> {
return this.request<Category>("/api/category", {
method: "POST",
body: JSON.stringify(data),
});
}
// Channel methods
async getChannelsByCategory(categoryId: string): Promise<Channel[]> {
try {
const response = await this.request<Channel[] | { data: Channel[] }>(
`/api/channel/category/${categoryId}`,
);
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
} catch (error) {
console.warn(
`Channels endpoint not available for category ${categoryId}`,
error,
);
return [];
}
}
async createChannel(data: {
type: "text" | "voice";
name: string;
description: string;
categoryId?: string;
admin: boolean;
requestingUserId: string;
requestingUserToken: string;
}): Promise<Channel> {
return this.request<Channel>("/api/channel", {
method: "POST",
body: JSON.stringify(data),
});
}
// Message methods
async getMessages(params: {
date: string;
channelId: string;
}): Promise<Message[]> {
const query = new URLSearchParams(params);
const response = await this.request<Message[] | { data: Message[] }>(
`/api/message?${query}`,
);
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
}
async sendMessage(data: {
channelId: string;
userId: string;
content: string;
token: string;
repliedMessageId?: string | null;
}): Promise<Message> {
return this.request<Message>("/api/message", {
method: "POST",
body: JSON.stringify(data),
});
}
// User methods
async getUsersByInstance(instanceId: string): Promise<BackendUser[]> {
const query = new URLSearchParams({ instanceId });
const response = await this.request<
BackendUser[] | { data: BackendUser[] }
>(`/api/user?${query}`);
if (Array.isArray(response)) {
return response;
}
if ("data" in response && Array.isArray(response.data)) {
return response.data;
}
return [];
}
async getUser(id: string): Promise<BackendUser> {
const response = await this.request<BackendUser | { data: BackendUser }>(
`/api/user/${id}`,
);
if ("data" in response) {
return response.data;
}
return response as BackendUser;
}
async createUser(data: {
username: string;
nickname?: string;
bio?: string;
picture?: string;
banner?: string;
status?: "online" | "offline" | "dnd" | "idle" | "invis";
admin?: boolean;
requestingUserId: string;
requestingUserToken: string;
passwordhash: string;
}): Promise<{ success: boolean; data?: BackendUser; error?: string }> {
try {
const response = await this.request<BackendUser>("/api/user", {
method: "POST",
body: JSON.stringify(data),
});
return { success: true, data: response };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
// Create singleton instance
export const apiClient = new ApiClient();

View File

@@ -0,0 +1,175 @@
import { apiClient, BackendUser } from "./api-client";
export interface LoginCredentials {
username: string;
password: string;
}
export interface RegisterData {
username: string;
password: string;
nickname?: string;
bio?: string;
picture?: string;
banner?: string;
}
export interface AuthResponse {
user: BackendUser;
token: string;
}
class AuthClient {
private baseUrl: string;
constructor(
baseUrl: string = import.meta.env.VITE_API_URL || "http://localhost:3000",
) {
this.baseUrl = baseUrl;
}
async login(credentials: LoginCredentials): Promise<AuthResponse> {
try {
const response = await fetch(`${this.baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: credentials.username,
password: credentials.password,
}),
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: "Login failed" }));
throw new Error(errorData.error || "Login failed");
}
const data: AuthResponse = await response.json();
return data;
} catch (error) {
console.error("Login failed:", error);
throw error instanceof Error ? error : new Error("Login failed");
}
}
async register(
data: RegisterData,
adminUser: { id: string; token: string },
): Promise<AuthResponse> {
try {
const createUserData = {
username: data.username,
nickname: data.nickname,
bio: data.bio,
picture: data.picture,
banner: data.banner,
status: "online" as const,
admin: false,
requestingUserId: adminUser.id,
requestingUserToken: adminUser.token,
passwordhash: data.password,
};
const response = await apiClient.createUser(createUserData);
if (!response.success || !response.data) {
throw new Error(response.error || "Registration failed");
}
try {
const loginResponse = await this.login({
username: data.username,
password: data.password,
});
return loginResponse;
} catch (loginError) {
throw new Error(
"Registration successful, but auto-login failed. Please login manually.",
);
}
} catch (error) {
console.error("Registration failed:", error);
throw error instanceof Error ? error : new Error("Registration failed");
}
}
async validateToken(
token: string,
userId: string,
): Promise<{ valid: boolean; user?: BackendUser }> {
try {
const response = await fetch(`${this.baseUrl}/api/auth/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token, userId }),
});
if (!response.ok) {
return { valid: false };
}
const data: { valid: boolean; user?: BackendUser } =
await response.json();
return data;
} catch (error) {
console.error("Token validation failed:", error);
return { valid: false };
}
}
async refreshToken(oldToken: string, userId: string): Promise<AuthResponse> {
try {
const response = await fetch(`${this.baseUrl}/api/auth/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ userId, oldToken }),
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: "Token refresh failed" }));
throw new Error(errorData.error || "Token refresh failed");
}
const data: AuthResponse = await response.json();
return data;
} catch (error) {
console.error("Token refresh failed:", error);
throw error instanceof Error ? error : new Error("Token refresh failed");
}
}
async logout(userId: string): Promise<{ success: boolean }> {
try {
const response = await fetch(`${this.baseUrl}/api/auth/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ userId }),
});
if (!response.ok) {
console.warn(
"Logout endpoint failed, but continuing with local logout",
);
}
const data = await response.json().catch(() => ({ success: true }));
return data;
} catch (error) {
console.warn("Logout request failed:", error);
return { success: true }; // Always succeed locally
}
}
}
export const authClient = new AuthClient();

View File

@@ -1,26 +1,20 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { Hash, Volume2, Users, Pin } from "lucide-react"; import {
Hash,
Volume2,
Users,
Pin,
MoreHorizontal,
Reply,
Plus,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Copy, Edit, Trash2, Reply, MoreHorizontal } from "lucide-react";
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
import { useChannelMessages } from "@/hooks/useChannel";
import { useUiStore } from "@/stores/uiStore";
import { useAuthStore } from "@/stores/authStore";
import { Message, User } from "@/types/database";
import { MessageProps } from "@/components/message/Message";
import SyntaxHighlighter from "react-syntax-highlighter"; import SyntaxHighlighter from "react-syntax-highlighter";
import { import {
dark, dark,
@@ -28,73 +22,162 @@ import {
} from "react-syntax-highlighter/dist/esm/styles/hljs"; } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
// Updated imports for API integration
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
import {
useChannelMessages,
useSendMessage,
useDeleteMessage,
usePinMessage,
useLoadMoreMessages,
} from "@/hooks/useMessages";
import { useUiStore } from "@/stores/uiStore";
import { useAuthStore } from "@/stores/authStore";
import { Message } from "@/lib/api-client";
// Modal imports
import { MessageActionsModal } from "@/components/modals/MessageActionsModal";
import { EditMessageModal } from "@/components/modals/EditMessageModal";
import { PinnedMessagesModal } from "@/components/modals/PinnedMessagesModal";
// User type for message component
interface MessageUser {
id: string;
username?: string;
userName?: string;
nickname?: string | null;
nickName?: string | null;
picture?: string | null;
}
// Message Props interface
interface MessageProps {
message: Message;
user: MessageUser;
currentUser: any;
replyTo?: Message;
replyToUser?: MessageUser;
onEdit?: (messageId: string) => void;
onDelete?: (messageId: string) => void;
onReply?: (messageId: string) => void;
onPin?: (messageId: string) => void;
canDelete?: boolean;
canPin?: boolean;
}
const MessageComponent: React.FC<MessageProps> = ({ const MessageComponent: React.FC<MessageProps> = ({
message, message,
user, user,
currentUser, currentUser,
replyTo, replyTo,
replyToUser,
onEdit, onEdit,
onDelete, onDelete,
onReply, onReply,
isGrouped, onPin,
canDelete = false,
canPin = false,
}) => { }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [showActionsModal, setShowActionsModal] = useState(false);
const formatTimestamp = (timestamp: string) => { const formatTimestamp = (timestamp: string) => {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true }); try {
const date = new Date(timestamp);
if (isNaN(date.getTime())) {
return "Invalid date";
}
return formatDistanceToNow(date, { addSuffix: true });
} catch (error) {
console.error("Error formatting timestamp:", timestamp, error);
return "Invalid date";
}
}; };
const isOwnMessage = currentUser?.id === message.userId; const isOwnMessage = currentUser?.id === message.userId;
const { mode } = useTheme(); const { mode } = useTheme();
// Get username with fallback
const username = user.username || user.userName || "Unknown User";
const displayName = user.nickname || user.nickName || username;
const isDeleted = (message as any).deleted;
if (isDeleted) {
return ( return (
<div className="px-4 py-2 opacity-50">
<div className="flex gap-3">
<div className="w-10 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-concord-secondary italic border border-border rounded px-3 py-2 bg-concord-tertiary/50">
This message has been deleted
{(message as any).deletedBy && (
<span className="text-xs block mt-1">
Deleted by {(message as any).deletedBy}
{(message as any).deletedAt &&
`${formatTimestamp((message as any).deletedAt)}`}
</span>
)}
</div>
</div>
</div>
</div>
);
}
return (
<>
<div <div
className={`group relative px-4 hover:bg-concord-secondary/50 transition-colors ${ className="group relative px-4 py-2 hover:bg-concord-secondary/50 transition-colors"
isGrouped ? "mt-0 py-0" : "mt-4"
}`}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div className="flex gap-3"> <div className="flex gap-3">
{/* Avatar - only show if not grouped */} {/* Avatar - always show */}
<div className="w-10 flex-shrink-0"> <div className="w-10 flex-shrink-0">
{!isGrouped && (
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
<AvatarImage <AvatarImage src={user.picture || undefined} alt={username} />
src={user.picture || undefined}
alt={user.username}
/>
<AvatarFallback className="text-sm bg-primary text-primary-foreground"> <AvatarFallback className="text-sm bg-primary text-primary-foreground">
{user.username.slice(0, 2).toUpperCase()} {username.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)}
</div> </div>
{/* Message content */} {/* Message content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Reply line and reference */} {/* Reply line and reference */}
{replyTo && ( {replyTo && replyToUser && (
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary"> <div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" /> <div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary"> <span className="font-medium text-concord-primary">
{replyTo?.user?.nickname || replyTo?.user?.username} {replyToUser.nickname ||
replyToUser.nickName ||
replyToUser.username ||
replyToUser.userName}
</span> </span>
<span className="truncate max-w-xs opacity-75"> <span className="truncate max-w-xs opacity-75">
{replyTo.content.replace(/```[\s\S]*?```/g, "[code]")} {replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</span> </span>
</div> </div>
)} )}
{/* Header - only show if not grouped */}
{!isGrouped && ( {/* Header - always show */}
<div className="flex items-baseline gap-2 mb-1"> <div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-concord-primary"> <span className="font-semibold text-concord-primary">
{user.nickname || user.username} {displayName}
</span> </span>
<span className="text-xs text-concord-secondary"> <span className="text-xs text-concord-secondary">
{formatTimestamp(message.createdAt)} {formatTimestamp(message.createdAt)}
</span> </span>
</div> {message.edited && (
<span className="text-xs text-concord-secondary opacity-60">
(edited)
</span>
)} )}
{(message as any).pinned && (
<Pin className="h-3 w-3 text-yellow-500" />
)}
</div>
{/* Message content with markdown */} {/* Message content with markdown */}
<div className="text-concord-primary leading-relaxed prose prose-sm max-w-none dark:prose-invert"> <div className="text-concord-primary leading-relaxed prose prose-sm max-w-none dark:prose-invert">
@@ -169,10 +252,11 @@ const MessageComponent: React.FC<MessageProps> = ({
), ),
}} }}
> >
{message.content} {message.text}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
</div> </div>
{/* Message actions */} {/* Message actions */}
{isHovered && ( {isHovered && (
<div className="absolute top-0 right-4 bg-concord-secondary border border-border rounded-md shadow-md flex"> <div className="absolute top-0 right-4 bg-concord-secondary border border-border rounded-md shadow-md flex">
@@ -185,60 +269,47 @@ const MessageComponent: React.FC<MessageProps> = ({
<Reply className="h-4 w-4" /> <Reply className="h-4 w-4" />
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 p-0 interactive-hover" className="h-8 w-8 p-0 interactive-hover"
onClick={() => setShowActionsModal(true)}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </div>
<DropdownMenuContent> )}
<DropdownMenuItem </div>
onClick={() => navigator.clipboard.writeText(message.content)} </div>
>
<Copy className="h-4 w-4 mr-2" /> {/* Message Actions Modal */}
Copy Text <MessageActionsModal
</DropdownMenuItem> isOpen={showActionsModal}
{isOwnMessage && ( onClose={() => setShowActionsModal(false)}
<> message={message}
<DropdownMenuItem onClick={() => onEdit?.(message.id)}> isOwnMessage={isOwnMessage}
<Edit className="h-4 w-4 mr-2" /> canDelete={canDelete}
Edit Message onEdit={onEdit}
</DropdownMenuItem> onDelete={onDelete}
<DropdownMenuSeparator /> onReply={onReply}
<DropdownMenuItem onPin={canPin ? onPin : undefined}
onClick={() => onDelete?.(message.id)} />
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Message
</DropdownMenuItem>
</> </>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</div>
); );
}; };
// Message Input Component // Message Input Component
interface MessageInputProps { interface MessageInputProps {
channelId: string;
channelName?: string; channelName?: string;
onSendMessage: (content: string) => void;
replyingTo?: Message | null; replyingTo?: Message | null;
onCancelReply?: () => void; onCancelReply?: () => void;
replyingToUser: User | null; replyingToUser: MessageUser | null;
} }
const MessageInput: React.FC<MessageInputProps> = ({ const MessageInput: React.FC<MessageInputProps> = ({
channelId,
channelName, channelName,
onSendMessage,
replyingTo, replyingTo,
onCancelReply, onCancelReply,
replyingToUser, replyingToUser,
@@ -247,6 +318,9 @@ const MessageInput: React.FC<MessageInputProps> = ({
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
// Use the API hook for sending messages
const sendMessageMutation = useSendMessage();
// Auto-resize textarea // Auto-resize textarea
useEffect(() => { useEffect(() => {
if (textareaRef.current) { if (textareaRef.current) {
@@ -255,18 +329,27 @@ const MessageInput: React.FC<MessageInputProps> = ({
} }
}, [content]); }, [content]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (content.trim()) { if (content.trim() && !sendMessageMutation.isPending) {
onSendMessage(content.trim()); try {
await sendMessageMutation.mutateAsync({
channelId,
content: content.trim(),
repliedMessageId: replyingTo?.id || null,
});
setContent(""); setContent("");
onCancelReply?.();
} catch (error) {
console.error("Failed to send message:", error);
}
} }
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
formRef.current?.requestSubmit(); // <-- Programmatically submit form formRef.current?.requestSubmit();
} }
}; };
@@ -278,7 +361,10 @@ const MessageInput: React.FC<MessageInputProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" /> <div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary"> <span className="font-medium text-concord-primary">
{replyingToUser.nickname || replyingToUser.username} {replyingToUser.nickname ||
replyingToUser.nickName ||
replyingToUser.username ||
replyingToUser.userName}
</span> </span>
</div> </div>
<Button <Button
@@ -291,7 +377,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
</Button> </Button>
</div> </div>
<div className="text-sm text-concord-primary truncate pl-2"> <div className="text-sm text-concord-primary truncate pl-2">
{replyingTo.content.replace(/```[\s\S]*?```/g, "[code]")} {replyingTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</div> </div>
</div> </div>
)} )}
@@ -304,14 +390,17 @@ const MessageInput: React.FC<MessageInputProps> = ({
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={`Message #${channelName || "channel"}`} placeholder={`Message #${channelName || "channel"}`}
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" disabled={sendMessageMutation.isPending}
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
style={{ style={{
minHeight: "44px", minHeight: "44px",
maxHeight: "200px", maxHeight: "200px",
}} }}
/> />
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary"> <div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
Press Enter to send Shift+Enter for new line {sendMessageMutation.isPending
? "Sending..."
: "Press Enter to send • Shift+Enter for new line"}
</div> </div>
</div> </div>
</form> </form>
@@ -321,40 +410,197 @@ const MessageInput: React.FC<MessageInputProps> = ({
const ChatPage: React.FC = () => { const ChatPage: React.FC = () => {
const { instanceId, channelId } = useParams(); const { instanceId, channelId } = useParams();
const { data: instance } = useInstanceDetails(instanceId);
const categories = instance?.categories;
const { data: channelMessages } = useChannelMessages(channelId);
const { toggleMemberList, showMemberList } = useUiStore();
const { user: currentUser } = useAuthStore();
const { data: users } = useInstanceMembers(instanceId);
// State for messages and interactions
const [messages, setMessages] = useState<Message[]>([]);
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
// Use sample current user if none exists // ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL LOGIC
const displayCurrentUser = // API hooks - called unconditionally
currentUser || users?.find((u) => u.id === "current"); const {
data: instance,
isLoading: instanceLoading,
error: instanceError,
} = useInstanceDetails(instanceId);
const {
data: channelMessages,
isLoading: messagesLoading,
error: messagesError,
} = useChannelMessages(channelId);
const { data: users, isLoading: usersLoading } =
useInstanceMembers(instanceId);
// Find current channel // UI state hooks - called unconditionally
const currentChannel = categories const { toggleMemberList, showMemberList } = useUiStore();
const { user: currentUser } = useAuthStore();
// Local state hooks - called unconditionally
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
const [editingMessage, setEditingMessage] = useState<Message | null>(null);
const [showPinnedMessages, setShowPinnedMessages] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesStartRef = useRef<HTMLDivElement>(null);
// API mutation hooks - called unconditionally
const deleteMessageMutation = useDeleteMessage();
const pinMessageMutation = usePinMessage();
const loadMoreMessagesMutation = useLoadMoreMessages(channelId);
// Memoized values - called unconditionally
const categories = instance?.categories;
const currentChannel = React.useMemo(() => {
return categories
?.flatMap((cat) => cat.channels) ?.flatMap((cat) => cat.channels)
?.find((ch) => ch.id === channelId); ?.find((ch) => ch.id === channelId);
}, [categories, channelId]);
// Update messages when channel messages load const userHasAccess = React.useMemo(() => {
useEffect(() => { if (!currentUser || !instanceId) return false;
if (channelMessages) { if (currentUser.admin) return true;
setMessages(channelMessages.map((msg) => ({ ...msg, replyToId: null }))); return currentUser.roles.some((role) => role.instanceId === instanceId);
} }, [currentUser, instanceId]);
}, [channelMessages]);
// Scroll to bottom when messages change const canDeleteMessages = React.useMemo(() => {
if (!currentUser || !instanceId) return false;
if (currentUser.admin) return true;
const userRole = currentUser.roles.find(
(role) => role.instanceId === instanceId,
);
return userRole && (userRole.role === "admin" || userRole.role === "mod");
}, [currentUser, instanceId]);
const canPinMessages = React.useMemo(() => {
if (!currentUser || !instanceId) return false;
if (currentUser.admin) return true;
const userRole = currentUser.roles.find(
(role) => role.instanceId === instanceId,
);
return userRole && (userRole.role === "admin" || userRole.role === "mod");
}, [currentUser, instanceId]);
// Effects - called unconditionally
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]); }, [channelMessages]);
// Event handlers
const handleLoadMore = React.useCallback(async () => {
if (!channelMessages || channelMessages.length === 0 || isLoadingMore)
return;
setIsLoadingMore(true);
try {
const oldestMessage = channelMessages[0];
await loadMoreMessagesMutation.mutateAsync({
beforeDate: new Date(oldestMessage.createdAt),
});
} catch (error) {
console.error("Failed to load more messages:", error);
} finally {
setIsLoadingMore(false);
}
}, [channelMessages, isLoadingMore, loadMoreMessagesMutation]);
const handleReply = React.useCallback(
(messageId: string) => {
const message = channelMessages?.find((m) => m.id === messageId);
if (message) {
setReplyingTo(message);
}
},
[channelMessages],
);
const handleEdit = React.useCallback(
(messageId: string) => {
const message = channelMessages?.find((m) => m.id === messageId);
if (message) {
setEditingMessage(message);
}
},
[channelMessages],
);
const handleDelete = React.useCallback(
async (messageId: string) => {
if (confirm("Are you sure you want to delete this message?")) {
try {
await deleteMessageMutation.mutateAsync({
messageId,
channelId: channelId!,
});
} catch (error) {
console.error("Failed to delete message:", error);
}
}
},
[deleteMessageMutation, channelId],
);
const handlePin = React.useCallback(
async (messageId: string) => {
try {
const message = channelMessages?.find((m) => m.id === messageId);
const isPinned = (message as any)?.pinned;
await pinMessageMutation.mutateAsync({
messageId,
channelId: channelId!,
pinned: !isPinned,
});
} catch (error) {
console.error("Failed to pin/unpin message:", error);
}
},
[pinMessageMutation, channelId, channelMessages],
);
// NOW WE CAN START CONDITIONAL LOGIC AND EARLY RETURNS
// Handle loading states
if (instanceLoading || messagesLoading || usersLoading) {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p>Loading chat...</p>
</div>
</div>
);
}
// Handle errors and permissions
if (!userHasAccess) {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<h2 className="text-xl font-semibold mb-2 text-destructive">
Access Denied
</h2>
<p className="mb-4">You don't have permission to view this server.</p>
<Button onClick={() => navigate("/channels/@me")}>Go Home</Button>
</div>
</div>
);
}
if (instanceError || messagesError) {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<h2 className="text-xl font-semibold mb-2 text-destructive">
Error Loading Chat
</h2>
<p className="mb-4">
{instanceError?.message ||
messagesError?.message ||
"Something went wrong"}
</p>
<Button onClick={() => window.location.reload()}>Retry</Button>
</div>
</div>
);
}
// Require both instanceId and channelId for chat // Require both instanceId and channelId for chat
if (!instanceId) { if (!instanceId) {
@@ -362,9 +608,9 @@ const ChatPage: React.FC = () => {
<div className="flex-1 flex items-center justify-center bg-concord-primary"> <div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary"> <div className="text-center text-concord-secondary">
<h2 className="text-xl font-semibold mb-2 text-concord-primary"> <h2 className="text-xl font-semibold mb-2 text-concord-primary">
No Channel Selected No Server Selected
</h2> </h2>
<p>Select a channel from the sidebar to start chatting.</p> <p>Select a server from the sidebar to start chatting.</p>
</div> </div>
</div> </div>
); );
@@ -373,9 +619,10 @@ const ChatPage: React.FC = () => {
?.flatMap((cat) => cat.channels) ?.flatMap((cat) => cat.channels)
?.find((channel) => channel.position === 0)?.id; ?.find((channel) => channel.position === 0)?.id;
if (existingChannelId) if (existingChannelId) {
navigate(`/channels/${instanceId}/${existingChannelId}`); navigate(`/channels/${instanceId}/${existingChannelId}`);
else return null;
} else {
return ( return (
<div className="flex-1 flex items-center justify-center bg-concord-primary"> <div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary"> <div className="text-center text-concord-secondary">
@@ -387,59 +634,11 @@ const ChatPage: React.FC = () => {
</div> </div>
); );
} }
}
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash; const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
// Message handlers console.log(channelMessages);
const handleSendMessage = (content: string) => {
if (!displayCurrentUser) return;
const newMessage: Message = {
id: Date.now().toString(),
content,
channelId: channelId || "",
userId: displayCurrentUser.id,
edited: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replyTo: replyingTo || null,
};
setMessages((prev) => [...prev, newMessage]);
setReplyingTo(null);
};
const handleReply = (messageId: string) => {
const message = messages.find((m) => m.id === messageId);
if (message) {
setReplyingTo(message);
}
};
const handleEdit = (messageId: string) => {
// TODO: Implement edit functionality
console.log("Edit message:", messageId);
};
const handleDelete = (messageId: string) => {
setMessages((prev) => prev.filter((m) => m.id !== messageId));
};
// Group messages by user and time
const groupedMessages = messages.reduce((acc, message, index) => {
const prevMessage = index > 0 ? messages[index - 1] : null;
const isGrouped =
prevMessage &&
prevMessage.userId === message.userId &&
!message.replyTo && // Don't group replies
!prevMessage.replyTo && // Don't group if previous was a reply
new Date(message.createdAt).getTime() -
new Date(prevMessage.createdAt).getTime() <
5 * 60 * 1000; // 5 minutes
acc.push({ ...message, isGrouped });
return acc;
}, [] as Message[]);
return ( return (
<div className="flex flex-col flex-shrink h-full bg-concord-primary"> <div className="flex flex-col flex-shrink h-full bg-concord-primary">
{/* Channel Header */} {/* Channel Header */}
@@ -464,6 +663,7 @@ const ChatPage: React.FC = () => {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 interactive-hover" className="h-8 w-8 interactive-hover"
onClick={() => setShowPinnedMessages(true)}
> >
<Pin size={16} /> <Pin size={16} />
</Button> </Button>
@@ -488,6 +688,28 @@ const ChatPage: React.FC = () => {
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Messages Area */} {/* Messages Area */}
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
{/* Load More Button */}
{channelMessages && channelMessages.length > 0 && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
onClick={handleLoadMore}
disabled={isLoadingMore}
className="text-xs"
>
{isLoadingMore ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary mr-2"></div>
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{isLoadingMore ? "Loading..." : "Load older messages"}
</Button>
</div>
)}
<div ref={messagesStartRef} />
{/* Welcome Message */} {/* Welcome Message */}
<div className="px-4 py-6 border-b border-concord/50 flex-shrink-0"> <div className="px-4 py-6 border-b border-concord/50 flex-shrink-0">
<div className="flex items-center space-x-3 mb-3"> <div className="flex items-center space-x-3 mb-3">
@@ -509,28 +731,33 @@ const ChatPage: React.FC = () => {
<div className="pb-4"> <div className="pb-4">
{/* Messages */} {/* Messages */}
{groupedMessages.length > 0 ? ( {channelMessages && channelMessages.length > 0 ? (
<div> <div>
{groupedMessages.map((message) => { {channelMessages.map((message) => {
const user = users?.find((u) => u.id === message.userId); const user = users?.find((u) => u.id === message.userId);
const replyToMessage = messages.find( const replyToMessage = channelMessages?.find(
(m) => m.id === message.replyTo?.id, (m) => m.id === message.replyToId,
); );
const replyToUser = replyToMessage?.user; const replyToUser = replyToMessage
? users?.find((u) => u.id === replyToMessage.userId)
: undefined;
if (!user) return null; if (!user) return null;
return ( return (
<MessageComponent <MessageComponent
key={message.id} key={message.id}
message={message} message={message}
user={user} user={user}
currentUser={displayCurrentUser} currentUser={currentUser}
replyTo={replyToMessage} replyTo={replyToMessage}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onReply={handleReply} onReply={handleReply}
onPin={handlePin}
replyToUser={replyToUser} replyToUser={replyToUser}
isGrouped={message.isGrouped} canDelete={canDeleteMessages}
canPin={canPinMessages}
/> />
); );
})} })}
@@ -548,10 +775,11 @@ const ChatPage: React.FC = () => {
</ScrollArea> </ScrollArea>
{/* Message Input */} {/* Message Input */}
{currentUser && (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<MessageInput <MessageInput
channelId={channelId}
channelName={currentChannel?.name} channelName={currentChannel?.name}
onSendMessage={handleSendMessage}
replyingTo={replyingTo} replyingTo={replyingTo}
onCancelReply={() => setReplyingTo(null)} onCancelReply={() => setReplyingTo(null)}
replyingToUser={ replyingToUser={
@@ -561,7 +789,27 @@ const ChatPage: React.FC = () => {
} }
/> />
</div> </div>
)}
</div> </div>
{/* Edit Message Modal */}
{editingMessage && (
<EditMessageModal
isOpen={!!editingMessage}
onClose={() => setEditingMessage(null)}
message={editingMessage}
channelId={channelId!}
/>
)}
{/* Pinned Messages Modal */}
<PinnedMessagesModal
isOpen={showPinnedMessages}
onClose={() => setShowPinnedMessages(false)}
channelId={channelId!}
channelName={currentChannel?.name || "channel"}
canManagePins={canPinMessages ? canPinMessages : false}
/>
</div> </div>
); );
}; };

View File

@@ -10,13 +10,17 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
import { useLogin } from "@/hooks/useAuth";
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const { isAuthenticated, setAuth } = useAuthStore(); const { isAuthenticated } = useAuthStore();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Use the real login hook
const { mutate: login, isPending, error } = useLogin();
// Redirect if already authenticated // Redirect if already authenticated
if (isAuthenticated) { if (isAuthenticated) {
@@ -25,35 +29,12 @@ const LoginPage: React.FC = () => {
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true);
try { if (!username.trim() || !password.trim()) {
// TODO: Replace with actual login API call return;
setTimeout(() => {
setAuth(
{
id: "1",
username,
nickname: username,
bio: "Test user",
picture: "",
banner: "",
hashPassword: "",
admin: false,
status: "online",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [],
},
"fake-token",
"fake-refresh-token",
);
setIsLoading(false);
}, 1000);
} catch (error) {
console.error("Login failed:", error);
setIsLoading(false);
} }
login({ username: username.trim(), password });
}; };
return ( return (
@@ -69,6 +50,16 @@ const LoginPage: React.FC = () => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleLogin} className="space-y-4"> <form onSubmit={handleLogin} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>
{error instanceof Error
? error.message
: "Login failed. Please try again."}
</AlertDescription>
</Alert>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username" className="text-concord-primary"> <Label htmlFor="username" className="text-concord-primary">
Username Username
@@ -80,9 +71,11 @@ const LoginPage: React.FC = () => {
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="bg-concord-tertiary border-concord text-concord-primary" className="bg-concord-tertiary border-concord text-concord-primary"
placeholder="Enter your username" placeholder="Enter your username"
disabled={isPending}
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" className="text-concord-primary"> <Label htmlFor="password" className="text-concord-primary">
Password Password
@@ -94,11 +87,17 @@ const LoginPage: React.FC = () => {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="bg-concord-tertiary border-concord text-concord-primary" className="bg-concord-tertiary border-concord text-concord-primary"
placeholder="Enter your password" placeholder="Enter your password"
disabled={isPending}
required required
/> />
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Log In"} <Button
type="submit"
className="w-full"
disabled={isPending || !username.trim() || !password.trim()}
>
{isPending ? "Logging in..." : "Log In"}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>

View File

@@ -7,10 +7,10 @@ import {
Mic, Mic,
Settings, Settings,
ChevronRight, ChevronRight,
Eye,
Moon, Moon,
Sun, Sun,
Monitor, Monitor,
Lock,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -25,6 +25,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ThemeSelector } from "@/components/theme-selector"; import { ThemeSelector } from "@/components/theme-selector";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
@@ -44,6 +45,12 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
icon: User, icon: User,
description: "Profile, privacy, and account settings", description: "Profile, privacy, and account settings",
}, },
{
id: "security",
title: "Security",
icon: Lock,
description: "Password and security settings",
},
{ {
id: "appearance", id: "appearance",
title: "Appearance", title: "Appearance",
@@ -58,6 +65,389 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
}, },
]; ];
const SecuritySettings: React.FC = () => {
const { user } = useAuthStore();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [passwordError, setPasswordError] = useState("");
const [passwordSuccess, setPasswordSuccess] = useState("");
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError("");
setPasswordSuccess("");
if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError("All password fields are required");
return;
}
if (newPassword !== confirmPassword) {
setPasswordError("New passwords do not match");
return;
}
if (newPassword.length < 8) {
setPasswordError("New password must be at least 8 characters long");
return;
}
setIsChangingPassword(true);
try {
// TODO: Implement actual password change API call
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
console.log("Changing password for user:", user?.id);
// const result = await authClient.changePassword({
// userId: user.id,
// currentPassword,
// newPassword,
// token: authStore.token
// });
setPasswordSuccess("Password changed successfully");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (error) {
setPasswordError(
"Failed to change password. Please check your current password.",
);
} finally {
setIsChangingPassword(false);
}
};
return (
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="h-5 w-5" />
Change Password
</CardTitle>
<CardDescription>
Update your password to keep your account secure.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{passwordError && (
<Alert variant="destructive">
<AlertDescription>{passwordError}</AlertDescription>
</Alert>
)}
{passwordSuccess && (
<Alert>
<AlertDescription>{passwordSuccess}</AlertDescription>
</Alert>
)}
<form onSubmit={handlePasswordChange} className="space-y-4">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="max-w-sm"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="max-w-sm"
minLength={8}
required
/>
<p className="text-xs text-muted-foreground">
Must be at least 8 characters long
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="max-w-sm"
required
/>
</div>
</div>
<div className="mt-6">
<Button
type="submit"
disabled={
isChangingPassword ||
!currentPassword ||
!newPassword ||
!confirmPassword
}
>
{isChangingPassword
? "Changing Password..."
: "Change Password"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Security Options
</CardTitle>
<CardDescription>
Additional security features for your account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Two-Factor Authentication
</Label>
<p className="text-sm text-muted-foreground">
Add an extra layer of security to your account
</p>
</div>
<Switch
checked={twoFactorEnabled}
onCheckedChange={setTwoFactorEnabled}
/>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-base font-medium">Active Sessions</Label>
<p className="text-sm text-muted-foreground mb-4">
Manage devices that are currently logged into your account
</p>
<Button variant="outline" size="sm">
View Active Sessions
</Button>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-base font-medium">Account Backup</Label>
<p className="text-sm text-muted-foreground mb-4">
Download a copy of your account data
</p>
<Button variant="outline" size="sm">
Request Data Export
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
const AccountSettings: React.FC = () => {
const { user, updateUser } = useAuthStore();
const [username, setUsername] = useState(user?.username || "");
const [nickname, setNickname] = useState(user?.nickname || "");
const [bio, setBio] = useState(user?.bio || "");
const [isChanged, setIsChanged] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [saveSuccess, setSaveSuccess] = useState("");
const handleSave = async () => {
setSaveError("");
setSaveSuccess("");
setIsSaving(true);
try {
// TODO: Implement actual profile update API call
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
console.log("Updating profile:", { username, nickname, bio });
// const updatedUser = await userClient.updateProfile({
// userId: user.id,
// username: username.trim(),
// nickname: nickname.trim() || null,
// bio: bio.trim() || null,
// token: authStore.token
// });
// Update local state
updateUser({
username: username.trim(),
nickname: nickname.trim() || null,
bio: bio.trim() || null,
});
setSaveSuccess("Profile updated successfully");
setIsChanged(false);
} catch (error) {
setSaveError("Failed to update profile. Please try again.");
} finally {
setIsSaving(false);
}
};
const handleChange = () => {
setIsChanged(true);
setSaveError("");
setSaveSuccess("");
};
return (
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Profile
</CardTitle>
<CardDescription>
Update your profile information and display settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{saveError && (
<Alert variant="destructive">
<AlertDescription>{saveError}</AlertDescription>
</Alert>
)}
{saveSuccess && (
<Alert>
<AlertDescription>{saveSuccess}</AlertDescription>
</Alert>
)}
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => {
setUsername(e.target.value);
handleChange();
}}
className="max-w-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="nickname">Display Name</Label>
<Input
id="nickname"
value={nickname}
onChange={(e) => {
setNickname(e.target.value);
handleChange();
}}
className="max-w-sm"
placeholder="How others see your name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="bio">Bio</Label>
<Input
id="bio"
value={bio}
onChange={(e) => {
setBio(e.target.value);
handleChange();
}}
className="max-w-sm"
placeholder="Tell others about yourself"
/>
</div>
</div>
<div className="mt-6 flex gap-2">
<Button onClick={handleSave} disabled={!isChanged || isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
{isChanged && (
<Button
variant="outline"
onClick={() => {
setUsername(user?.username || "");
setNickname(user?.nickname || "");
setBio(user?.bio || "");
setIsChanged(false);
setSaveError("");
setSaveSuccess("");
}}
>
Reset
</Button>
)}
</div>
</CardContent>
</Card>
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Privacy
</CardTitle>
<CardDescription>
Control who can contact you and see your information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Allow Direct Messages
</Label>
<p className="text-sm text-muted-foreground">
Let other users send you direct messages
</p>
</div>
<Switch defaultChecked />
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Show Online Status
</Label>
<p className="text-sm text-muted-foreground">
Display when you're online to other users
</p>
</div>
<Switch defaultChecked />
</div>
</CardContent>
</Card>
</div>
);
};
// Appearance Settings Component (keeping existing implementation)
const AppearanceSettings: React.FC = () => { const AppearanceSettings: React.FC = () => {
const { const {
currentLightTheme, currentLightTheme,
@@ -67,12 +457,6 @@ const AppearanceSettings: React.FC = () => {
setMode, setMode,
setTheme, setTheme,
} = useTheme(); } = useTheme();
const [compactMode, setCompactMode] = useState(false);
const [showTimestamps, setShowTimestamps] = useState(true);
const [animationsEnabled, setAnimationsEnabled] = useState(true);
const [reduceMotion, setReduceMotion] = useState(false);
const [highContrast, setHighContrast] = useState(false);
const lightThemes = getThemesForMode("light"); const lightThemes = getThemesForMode("light");
const darkThemes = getThemesForMode("dark"); const darkThemes = getThemesForMode("dark");
@@ -245,344 +629,13 @@ const AppearanceSettings: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
{/* Theme Grid */}
<div className="mt-6">
<Label className="text-sm font-medium">Available Themes</Label>
<div className="mt-3 grid grid-cols-2 gap-3">
{/* Light Themes */}
{lightThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={`p-3 rounded-lg border-2 transition-all text-left ${
currentLightTheme.id === theme.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
<Sun className="h-4 w-4 text-yellow-500" />
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mb-2">
{theme.description}
</p>
)}
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
</div>
</button>
))}
{/* Dark Themes */}
{darkThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={`p-3 rounded-lg border-2 transition-all text-left ${
currentDarkTheme.id === theme.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
<Moon className="h-4 w-4 text-blue-400" />
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mb-2">
{theme.description}
</p>
)}
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
</div>
</button>
))}
</div>
</div>
{/* Theme Stats */}
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold">{lightThemes.length}</div>
<div className="text-sm text-muted-foreground">Light Themes</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold">{darkThemes.length}</div>
<div className="text-sm text-muted-foreground">Dark Themes</div>
</div>
</div>
</CardContent>
</Card>
{/* Display Settings */}
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Display Settings
</CardTitle>
<CardDescription>
Customize how content is displayed in the app.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Compact Mode</Label>
<p className="text-sm text-muted-foreground">
Use less space between messages and interface elements
</p>
</div>
<Switch checked={compactMode} onCheckedChange={setCompactMode} />
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Show Message Timestamps
</Label>
<p className="text-sm text-muted-foreground">
Display timestamps next to messages
</p>
</div>
<Switch
checked={showTimestamps}
onCheckedChange={setShowTimestamps}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Enable Animations</Label>
<p className="text-sm text-muted-foreground">
Play animations throughout the interface
</p>
</div>
<Switch
checked={animationsEnabled}
onCheckedChange={setAnimationsEnabled}
/>
</div>
</CardContent>
</Card>
{/* Accessibility */}
<Card className="w-full p-6">
<CardHeader>
<CardTitle>Accessibility</CardTitle>
<CardDescription>
Settings to improve accessibility and usability.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Reduce Motion</Label>
<p className="text-sm text-muted-foreground">
Reduce motion and animations for users with vestibular disorders
</p>
</div>
<Switch checked={reduceMotion} onCheckedChange={setReduceMotion} />
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">High Contrast</Label>
<p className="text-sm text-muted-foreground">
Increase contrast for better visibility
</p>
</div>
<Switch checked={highContrast} onCheckedChange={setHighContrast} />
</div>
</CardContent>
</Card>
</div>
);
};
const AccountSettings: React.FC = () => {
const { user } = useAuthStore();
const [username, setUsername] = useState(user?.username || "");
const [nickname, setNickname] = useState(user?.nickname || "");
const [bio, setBio] = useState(user?.bio || "");
const [email, setEmail] = useState("user@example.com");
const [isChanged, setIsChanged] = useState(false);
const handleSave = () => {
console.log("Saving profile changes:", { username, nickname, bio, email });
setIsChanged(false);
};
const handleChange = () => {
setIsChanged(true);
};
return (
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Profile
</CardTitle>
<CardDescription>
Update your profile information and display settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
handleChange();
}}
className="max-w-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => {
setUsername(e.target.value);
handleChange();
}}
className="max-w-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="nickname">Display Name</Label>
<Input
id="nickname"
value={nickname}
onChange={(e) => {
setNickname(e.target.value);
handleChange();
}}
className="max-w-sm"
placeholder="How others see your name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="bio">Bio</Label>
<Input
id="bio"
value={bio}
onChange={(e) => {
setBio(e.target.value);
handleChange();
}}
className="max-w-sm"
placeholder="Tell others about yourself"
/>
</div>
</div>
<div className="mt-6 flex gap-2">
<Button onClick={handleSave} disabled={!isChanged}>
Save Changes
</Button>
{isChanged && (
<Button
variant="outline"
onClick={() => {
setUsername(user?.username || "");
setNickname(user?.nickname || "");
setBio(user?.bio || "");
setEmail("user@example.com");
setIsChanged(false);
}}
>
Reset
</Button>
)}
</div>
</CardContent>
</Card>
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Privacy
</CardTitle>
<CardDescription>
Control who can contact you and see your information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Allow Direct Messages
</Label>
<p className="text-sm text-muted-foreground">
Let other users send you direct messages
</p>
</div>
<Switch defaultChecked />
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Show Online Status
</Label>
<p className="text-sm text-muted-foreground">
Display when you're online to other users
</p>
</div>
<Switch defaultChecked />
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
}; };
// Voice Settings Component
const VoiceSettings: React.FC = () => { const VoiceSettings: React.FC = () => {
const [inputVolume, setInputVolume] = useState(75); const [inputVolume, setInputVolume] = useState(75);
const [outputVolume, setOutputVolume] = useState(100); const [outputVolume, setOutputVolume] = useState(100);
@@ -700,18 +753,20 @@ const VoiceSettings: React.FC = () => {
const SettingsPage: React.FC = () => { const SettingsPage: React.FC = () => {
const { section } = useParams(); const { section } = useParams();
const currentSection = section || "appearance"; const currentSection = section || "account";
const renderSettingsContent = () => { const renderSettingsContent = () => {
switch (currentSection) { switch (currentSection) {
case "account": case "account":
return <AccountSettings />; return <AccountSettings />;
case "security":
return <SecuritySettings />;
case "appearance": case "appearance":
return <AppearanceSettings />; return <AppearanceSettings />;
case "voice": case "voice":
return <VoiceSettings />; return <VoiceSettings />;
default: default:
return <AppearanceSettings />; return <AccountSettings />;
} }
}; };

View File

@@ -28,14 +28,15 @@ export interface Channel {
updatedAt: string; updatedAt: string;
} }
export type UserStatus = "online" | "away" | "busy" | "offline";
export interface User { export interface User {
id: string; id: string;
username: string; username: string;
nickname?: string; nickname?: string | null;
bio?: string; bio?: string | null;
picture?: string | null; picture?: string | null;
banner?: string | null; banner?: string | null;
hashPassword: string; // Won't be sent to client hashPassword: string;
admin: boolean; admin: boolean;
status: "online" | "away" | "busy" | "offline"; status: "online" | "away" | "busy" | "offline";
createdAt: string; createdAt: string;

View File

@@ -0,0 +1,46 @@
import { BackendUser } from "@/lib/api-client";
import { Message } from "./database";
// API types
export type {
ApiResponse,
Instance,
Category,
Channel,
BackendUser,
Message,
} from "@/lib/api-client";
// Auth types
export type {
LoginCredentials,
RegisterData,
AuthResponse,
} from "@/lib/auth-client";
// Hook types
export type { CategoryWithChannels, InstanceWithDetails } from "@/types/api";
// Frontend User type (for compatibility with existing components)
export interface User {
id: string;
username: string;
nickname?: string | null;
bio?: string | null;
picture?: string | null;
banner?: string | null;
hashPassword: string;
admin: boolean;
status: string;
createdAt: string;
updatedAt: string;
roles: Array<{
instanceId: string;
role: string;
}>;
}
// Message with user for chat components
export interface MessageWithUser extends Message {
user?: User | BackendUser;
}

View File

@@ -0,0 +1,198 @@
import { User, Role } from "@/types/database";
export type UserPermission =
| "view_instance"
| "create_channel"
| "delete_channel"
| "create_category"
| "delete_category"
| "manage_instance"
| "delete_messages"
| "pin_messages"
| "manage_users"
| "create_instance"
| "manage_roles";
export type UserRole = "admin" | "mod" | "member";
// Check if user has a specific role in an instance
export function hasInstanceRole(
user: User | null,
instanceId: string,
role: UserRole,
): boolean {
if (!user) return false;
// Global admins have all permissions
if (user.admin) return true;
const userRole = user.roles.find((r) => r.instanceId === instanceId);
if (!userRole) return false;
switch (role) {
case "admin":
return userRole.role === "admin";
case "mod":
return userRole.role === "admin" || userRole.role === "mod";
case "member":
return (
userRole.role === "admin" ||
userRole.role === "mod" ||
userRole.role === "member"
);
default:
return false;
}
}
// Check if user has access to view an instance
export function canViewInstance(
user: User | null,
instanceId: string,
): boolean {
if (!user) return false;
// Global admins can view all instances
if (user.admin) return true;
// Check if user has any role in this instance
return user.roles.some((role) => role.instanceId === instanceId);
}
// Check if user has a specific permission in an instance
export function hasPermission(
user: User | null,
instanceId: string,
permission: UserPermission,
): boolean {
if (!user) return false;
// Global admins have all permissions everywhere
if (user.admin) return true;
const userRole = user.roles.find((r) => r.instanceId === instanceId);
if (!userRole) return false;
switch (permission) {
case "view_instance":
return hasInstanceRole(user, instanceId, "member");
case "create_channel":
case "delete_channel":
case "create_category":
case "delete_category":
case "manage_instance":
case "manage_users":
case "manage_roles":
return hasInstanceRole(user, instanceId, "admin");
case "delete_messages":
case "pin_messages":
return hasInstanceRole(user, instanceId, "mod");
case "create_instance":
return user.admin; // Only global admins can create instances
default:
return false;
}
}
// Get user's role in a specific instance
export function getUserRole(
user: User | null,
instanceId: string,
): UserRole | null {
if (!user) return null;
// Global admins are always admins
if (user.admin) return "admin";
const userRole = user.roles.find((r) => r.instanceId === instanceId);
return userRole ? (userRole.role as UserRole) : null;
}
// Filter instances that user can access
export function getAccessibleInstances(
user: User | null,
instances: any[],
): any[] {
if (!user) return [];
// Global admins can see all instances
if (user.admin) return instances;
// Filter instances where user has a role
const userInstanceIds = new Set(user.roles.map((role) => role.instanceId));
return instances.filter((instance) => userInstanceIds.has(instance.id));
}
// Check if user can delete a specific message
export function canDeleteMessage(
user: User | null,
instanceId: string,
messageUserId: string,
): boolean {
if (!user) return false;
// Users can always delete their own messages
if (user.id === messageUserId) return true;
// Mods and admins can delete any message
return hasPermission(user, instanceId, "delete_messages");
}
// Check if user can edit a specific message
export function canEditMessage(
user: User | null,
messageUserId: string,
): boolean {
if (!user) return false;
// Users can only edit their own messages
return user.id === messageUserId;
}
// Check if user can pin messages
export function canPinMessage(user: User | null, instanceId: string): boolean {
return hasPermission(user, instanceId, "pin_messages");
}
// Check if user is global admin
export function isGlobalAdmin(user: User | null): boolean {
return user?.admin === true;
}
// Helper to get role display info
export function getRoleDisplayInfo(role: UserRole) {
switch (role) {
case "admin":
return {
name: "Admin",
color: "#ff6b6b",
priority: 3,
description: "Full server permissions",
};
case "mod":
return {
name: "Moderator",
color: "#4ecdc4",
priority: 2,
description: "Can moderate messages and users",
};
case "member":
return {
name: "Member",
color: null,
priority: 1,
description: "Basic server access",
};
default:
return {
name: "Unknown",
color: null,
priority: 0,
description: "Unknown role",
};
}
}

View File

@@ -104,7 +104,7 @@ model Message {
User User @relation(fields: [userId], references: [id]) User User @relation(fields: [userId], references: [id])
userId String userId String
deleted Boolean deleted Boolean
text String text String @db.VarChar(2000)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
replies Reply? @relation("MessageToReply") replies Reply? @relation("MessageToReply")

View File

@@ -2,6 +2,7 @@ import {
getAllUsersFrom, getAllUsersFrom,
getUserInformation, getUserInformation,
createUser, createUser,
getUserId,
} from "../services/userService"; } from "../services/userService";
import { CreateUserInput } from "../validators/userValidator"; import { CreateUserInput } from "../validators/userValidator";
@@ -16,3 +17,7 @@ export async function fetchAllUsers(instanceId: string) {
export async function createNewUser(data: CreateUserInput) { export async function createNewUser(data: CreateUserInput) {
return await createUser(data); return await createUser(data);
} }
export async function fetchUserId(username: string) {
return await getUserId(username);
}

View File

@@ -3,6 +3,7 @@ import { zValidator } from "@hono/zod-validator";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
import { import {
getUserCredentials, getUserCredentials,
getUserId,
getUserInformation, getUserInformation,
} from "../services/userService"; } from "../services/userService";
import shaHash from "../helper/hashing"; import shaHash from "../helper/hashing";
@@ -45,6 +46,7 @@ authRoutes.post(
async (c) => { async (c) => {
try { try {
const { username, password } = await c.req.json(); const { username, password } = await c.req.json();
console.log(c.req.json);
// Find user by username // Find user by username
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
@@ -55,21 +57,29 @@ authRoutes.post(
return c.json({ error: "Invalid username or password" }, 401); return c.json({ error: "Invalid username or password" }, 401);
} }
// Get user credentials // get userId
const userCredentials = await getUserCredentials(user.id); const userIdResult = await getUserId(username);
if (!userIdResult) {
return c.json({ error: "Invalid username or password" }, 401);
}
const userId = userIdResult.userId;
// get user creds
const userCredentials = await getUserCredentials(userId);
if (!userCredentials) { if (!userCredentials) {
return c.json({ error: "Invalid username or password" }, 401); return c.json({ error: "Invalid username or password" }, 401);
} }
// Hash // hash the provided password with user ID as salt
// const hashedPassword = shaHash(password, user.id); const hashedPassword = shaHash(password, userId);
// Verify password // verify password
if (password !== userCredentials.password) { if (hashedPassword !== userCredentials.password) {
return c.json({ error: "Invalid username or password" }, 401); return c.json({ error: "Invalid username or password" }, 401);
} }
// Generate new token // generate new token
const token = crypto.randomUUID(); const token = crypto.randomUUID();
// Update user's token in database // Update user's token in database
@@ -78,7 +88,7 @@ authRoutes.post(
data: { token: token }, data: { token: token },
}); });
// Get full user information // get full user information
const userInfo = await getUserInformation(user.id); const userInfo = await getUserInformation(user.id);
if (!userInfo) { if (!userInfo) {
return c.json({ error: "Failed to get user information" }, 500); return c.json({ error: "Failed to get user information" }, 500);

View File

@@ -3,16 +3,49 @@ import {
fetchAllUsers, fetchAllUsers,
fetchUserData, fetchUserData,
createNewUser, createNewUser,
fetchUserId,
} from "../controller/userController"; } from "../controller/userController";
import { import {
createUserSchema, createUserSchema,
queryAllUsersByInstanceId, queryAllUsersByInstanceId,
queryUserByIdSchema, queryUserByIdSchema,
queryUserByUsernameSchema,
} from "../validators/userValidator"; } from "../validators/userValidator";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
const userRoutes = new Hono(); const userRoutes = new Hono();
userRoutes.get(
"/username/:username",
describeRoute({
description: "Get userId by username",
responses: {
200: {
description: "Success getting userId",
content: {
"application/json": { schema: resolver(queryUserByUsernameSchema) },
},
},
404: {
description: "userId not found",
content: {
"application/json": { schema: resolver(queryUserByUsernameSchema) },
},
},
},
}),
zValidator("param", queryUserByUsernameSchema),
async (c) => {
const username = c.req.param("username");
const userId = await fetchUserId(username);
if (userId) {
return c.json(userId);
} else {
return c.json({ error: "User not found" }, 404);
}
},
);
userRoutes.get( userRoutes.get(
"/:id", "/:id",
describeRoute({ describeRoute({

View File

@@ -64,6 +64,39 @@ export async function createUser(data: CreateUserInput): Promise<{
return userData; return userData;
} }
export async function getUserId(
username: string,
): Promise<{ userId: string } | null> {
try {
if (!username) throw new Error("missing username");
const user = await prisma.user.findFirst({
where: {
username: username,
},
});
if (!user) throw new Error("could not find user");
return {
userId: user.id,
};
} catch (err) {
const errMessage = err as Error;
if (errMessage.message === "missing username") {
console.log("services::actions::getUserId - no username given");
return null;
}
if (errMessage.message === "could not find user") {
console.log("services::actions::getUserId - unable to find user");
return null;
}
console.log("services::actions::getUserId - unknown error");
return null;
}
}
export async function getUserCredentials(userId: string): Promise<{ export async function getUserCredentials(userId: string): Promise<{
userId: string; userId: string;
password: string; password: string;

View File

@@ -4,6 +4,10 @@ export const queryUserByIdSchema = z.object({
id: z.uuidv7(), id: z.uuidv7(),
}); });
export const queryUserByUsernameSchema = z.object({
username: z.string().min(3).max(30),
});
export const queryAllUsersByInstanceId = z.object({ export const queryAllUsersByInstanceId = z.object({
instanceId: z.uuidv7(), instanceId: z.uuidv7(),
}); });