@chesapeake/stack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/LICENSE +190 -0
- package/README.md +440 -0
- package/components/ui/avatar.tsx +87 -0
- package/components/ui/badge.tsx +49 -0
- package/components/ui/badgeVariants.ts +26 -0
- package/components/ui/button-group.tsx +67 -0
- package/components/ui/button.tsx +70 -0
- package/components/ui/buttonGroupVariants.ts +23 -0
- package/components/ui/buttonVariants.ts +38 -0
- package/components/ui/calendar.tsx +220 -0
- package/components/ui/card.tsx +162 -0
- package/components/ui/dialog.tsx +290 -0
- package/components/ui/drawer.tsx +227 -0
- package/components/ui/dropdown-menu.tsx +260 -0
- package/components/ui/index.ts +86 -0
- package/components/ui/input.tsx +59 -0
- package/components/ui/label.tsx +47 -0
- package/components/ui/location-input.md +194 -0
- package/components/ui/location-input.tsx +384 -0
- package/components/ui/popover.tsx +110 -0
- package/components/ui/select.tsx +306 -0
- package/components/ui/separator.tsx +50 -0
- package/components/ui/sonner-api.ts +8 -0
- package/components/ui/sonner.tsx +69 -0
- package/components/ui/textarea.tsx +53 -0
- package/components/ui/tooltip.tsx +125 -0
- package/components/ux/ActivityAnimation.tsx +135 -0
- package/components/ux/EmptyState.tsx +22 -0
- package/components/ux/FeedbackDrawer.tsx +141 -0
- package/components/ux/FireAnimation.tsx +189 -0
- package/components/ux/FriendList.tsx +111 -0
- package/components/ux/FriendRequestList.tsx +104 -0
- package/components/ux/GifSearch.tsx +264 -0
- package/components/ux/ImageUpload.tsx +829 -0
- package/components/ux/SelectedUsersList.tsx +91 -0
- package/components/ux/StandardLayout/PageLayout.tsx +107 -0
- package/components/ux/StandardLayout/ProfilePage.tsx +452 -0
- package/components/ux/StandardLayout/StandardLayout.tsx +345 -0
- package/components/ux/StandardLayout/index.ts +22 -0
- package/components/ux/TutorialCarousel.tsx +128 -0
- package/components/ux/UserSearch.tsx +324 -0
- package/components/ux/UserSelector.tsx +116 -0
- package/components/ux/auth/AuthSelector.tsx +154 -0
- package/components/ux/auth/MultiAuthProvider.tsx +196 -0
- package/components/ux/auth/README.md +317 -0
- package/components/ux/auth/context.ts +46 -0
- package/components/ux/auth/github/AuthCallback.tsx +177 -0
- package/components/ux/auth/github/AuthProvider.tsx +111 -0
- package/components/ux/auth/github/README.md +208 -0
- package/components/ux/auth/github/client.ts +127 -0
- package/components/ux/auth/github/config.ts +68 -0
- package/components/ux/auth/github/context.ts +34 -0
- package/components/ux/auth/github/default-client.ts +74 -0
- package/components/ux/auth/github/index.ts +43 -0
- package/components/ux/auth/github/types.ts +44 -0
- package/components/ux/auth/github/useGitHubAuth.ts +16 -0
- package/components/ux/auth/google/AuthCallback.tsx +170 -0
- package/components/ux/auth/google/AuthProvider.tsx +108 -0
- package/components/ux/auth/google/README.md +450 -0
- package/components/ux/auth/google/client.ts +109 -0
- package/components/ux/auth/google/config.ts +65 -0
- package/components/ux/auth/google/context.ts +34 -0
- package/components/ux/auth/google/default-client.ts +69 -0
- package/components/ux/auth/google/index.ts +43 -0
- package/components/ux/auth/google/types.ts +42 -0
- package/components/ux/auth/google/useGoogleAuth.ts +16 -0
- package/components/ux/auth/index.ts +72 -0
- package/components/ux/auth/useAuth.ts +16 -0
- package/components/ux/index.ts +46 -0
- package/components/ux/theme-provider-context.ts +20 -0
- package/components/ux/theme-provider.tsx +114 -0
- package/components/ux/useTheme.ts +42 -0
- package/index.ts +29 -0
- package/lib/utils.ts +57 -0
- package/package.json +170 -0
- package/styles/base.css +38 -0
- package/styles/components.css +54 -0
- package/styles/index.css +7 -0
- package/styles/tokens.css +189 -0
- package/tailwind.preset.ts +169 -0
- package/types/user.ts +69 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** @jsxImportSource react */
|
|
5
|
+
import { Button } from "../ui/button.tsx";
|
|
6
|
+
import { X } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
export interface SelectedUser {
|
|
9
|
+
userId: string;
|
|
10
|
+
displayName: string;
|
|
11
|
+
email?: string;
|
|
12
|
+
profilePicture?: string;
|
|
13
|
+
status?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SelectedUsersListProps {
|
|
17
|
+
users: SelectedUser[];
|
|
18
|
+
onRemove: (userId: string) => void;
|
|
19
|
+
showStatus?: boolean;
|
|
20
|
+
maxHeight?: string;
|
|
21
|
+
emptyMessage?: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SelectedUsersList({
|
|
26
|
+
users,
|
|
27
|
+
onRemove,
|
|
28
|
+
showStatus,
|
|
29
|
+
maxHeight,
|
|
30
|
+
emptyMessage,
|
|
31
|
+
title,
|
|
32
|
+
}: SelectedUsersListProps) {
|
|
33
|
+
if (users.length === 0) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-2">
|
|
36
|
+
{title && <h3 className="font-medium">{title}</h3>}
|
|
37
|
+
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-2">
|
|
44
|
+
{title && <h3 className="font-medium">{title}</h3>}
|
|
45
|
+
<div className="space-y-2 overflow-y-auto" style={{ maxHeight }}>
|
|
46
|
+
{users.map((user) => (
|
|
47
|
+
<div
|
|
48
|
+
key={user.userId}
|
|
49
|
+
className="flex items-center justify-between p-3 border rounded-lg"
|
|
50
|
+
>
|
|
51
|
+
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
52
|
+
{user.profilePicture && (
|
|
53
|
+
<img
|
|
54
|
+
src={user.profilePicture}
|
|
55
|
+
alt={user.displayName}
|
|
56
|
+
className="w-8 h-8 rounded-full object-cover flex-shrink-0"
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
<div className="flex-1 min-w-0">
|
|
60
|
+
<div className="font-medium truncate">{user.displayName}</div>
|
|
61
|
+
{user.email && (
|
|
62
|
+
<div className="text-sm text-muted-foreground truncate">
|
|
63
|
+
{user.email}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
{showStatus && user.status && (
|
|
67
|
+
<div className="text-xs text-muted-foreground capitalize mt-1">
|
|
68
|
+
{user.status}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="ml-4 flex-shrink-0">
|
|
74
|
+
<Button
|
|
75
|
+
type="button"
|
|
76
|
+
variant="destructive"
|
|
77
|
+
size="sm"
|
|
78
|
+
onClick={() => {
|
|
79
|
+
onRemove(user.userId);
|
|
80
|
+
}}
|
|
81
|
+
className="h-8 w-8 p-0"
|
|
82
|
+
>
|
|
83
|
+
<X className="h-4 w-4" />
|
|
84
|
+
</Button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../../../lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface PageLayoutProps {
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PageLayout provides a consistent structure for full-page layouts
|
|
14
|
+
* with proper container, padding, and spacing.
|
|
15
|
+
*/
|
|
16
|
+
export function PageLayout({ children, className }: PageLayoutProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className={cn("min-h-screen bg-background text-foreground", className)}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PageHeaderProps {
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* PageHeader provides consistent header styling with border and background.
|
|
33
|
+
* Responsive padding and wrapping for mobile devices.
|
|
34
|
+
*/
|
|
35
|
+
export function PageHeader({ children, className }: PageHeaderProps) {
|
|
36
|
+
return (
|
|
37
|
+
<header className={cn("border-b bg-card", className)}>
|
|
38
|
+
<div className="container mx-auto px-4 sm:px-6 py-4">{children}</div>
|
|
39
|
+
</header>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PageContentProps {
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
className?: string;
|
|
46
|
+
containerClassName?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PageContent provides consistent main content area with container and padding.
|
|
51
|
+
* Responsive padding for mobile and desktop.
|
|
52
|
+
*/
|
|
53
|
+
export function PageContent({
|
|
54
|
+
children,
|
|
55
|
+
className,
|
|
56
|
+
containerClassName,
|
|
57
|
+
}: PageContentProps) {
|
|
58
|
+
return (
|
|
59
|
+
<main
|
|
60
|
+
className={cn(
|
|
61
|
+
"container mx-auto px-4 sm:px-6 py-6 sm:py-8",
|
|
62
|
+
containerClassName
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<div className={className}>{children}</div>
|
|
66
|
+
</main>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PageContainerProps {
|
|
71
|
+
children: React.ReactNode;
|
|
72
|
+
className?: string;
|
|
73
|
+
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "full";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* PageContainer provides consistent content container with max-width constraints.
|
|
78
|
+
* Useful for centering content with appropriate max-widths.
|
|
79
|
+
* Responsive spacing for mobile and desktop.
|
|
80
|
+
*/
|
|
81
|
+
export function PageContainer({
|
|
82
|
+
children,
|
|
83
|
+
className,
|
|
84
|
+
maxWidth,
|
|
85
|
+
}: PageContainerProps) {
|
|
86
|
+
const maxWidthClasses = {
|
|
87
|
+
sm: "max-w-sm",
|
|
88
|
+
md: "max-w-md",
|
|
89
|
+
lg: "max-w-lg",
|
|
90
|
+
xl: "max-w-xl",
|
|
91
|
+
"2xl": "max-w-2xl",
|
|
92
|
+
"4xl": "max-w-4xl",
|
|
93
|
+
full: "max-w-full",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
className={cn(
|
|
99
|
+
"mx-auto space-y-4 sm:space-y-6",
|
|
100
|
+
maxWidth ? maxWidthClasses[maxWidth] : undefined,
|
|
101
|
+
className
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{children}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** @jsxImportSource react */
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { Button } from "../../../components/ui/button";
|
|
7
|
+
import { Card } from "../../../components/ui/card";
|
|
8
|
+
import {
|
|
9
|
+
Tooltip,
|
|
10
|
+
TooltipContent,
|
|
11
|
+
TooltipTrigger,
|
|
12
|
+
} from "../../../components/ui/tooltip";
|
|
13
|
+
import { AuthSelector, type AuthUser, useAuth } from "../auth";
|
|
14
|
+
import { FriendList, FriendRequestList, UserSearch } from "../index";
|
|
15
|
+
import type { BaseUser } from "../../../types/user";
|
|
16
|
+
import type { Friend } from "../FriendList";
|
|
17
|
+
import type { FriendRequest } from "../FriendRequestList";
|
|
18
|
+
|
|
19
|
+
export interface ProfilePageProps {
|
|
20
|
+
/** Callback when back button is clicked */
|
|
21
|
+
onBack?: () => void;
|
|
22
|
+
/** Enable Google OAuth login */
|
|
23
|
+
enableGoogle?: boolean;
|
|
24
|
+
/** Enable GitHub OAuth login */
|
|
25
|
+
enableGithub?: boolean;
|
|
26
|
+
/** Callback when authentication is successful */
|
|
27
|
+
onAuthSuccess?: (user: AuthUser, provider: "google" | "github") => void;
|
|
28
|
+
/** Full URL or path for fetching friends list */
|
|
29
|
+
friendsEndpoint?: string;
|
|
30
|
+
/** Full URL or path for fetching friend requests */
|
|
31
|
+
friendRequestsEndpoint?: string;
|
|
32
|
+
/** Full URL or path for user search endpoint */
|
|
33
|
+
userSearchEndpoint?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Base URL for API endpoints.
|
|
36
|
+
* Auth endpoints will be prefixed with this URL.
|
|
37
|
+
* @default ""
|
|
38
|
+
* @example "https://api.example.com" or "/api"
|
|
39
|
+
*/
|
|
40
|
+
apiBaseUrl?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Endpoint for checking authentication status.
|
|
43
|
+
* @default "/auth/me"
|
|
44
|
+
*/
|
|
45
|
+
authMeEndpoint?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Endpoint for logout.
|
|
48
|
+
* @default "/auth/logout"
|
|
49
|
+
*/
|
|
50
|
+
logoutEndpoint?: string;
|
|
51
|
+
/** Additional CSS classes */
|
|
52
|
+
className?: string;
|
|
53
|
+
/** App-specific settings/content to render at the bottom */
|
|
54
|
+
children?: React.ReactNode;
|
|
55
|
+
/** Hide the header (for use in StandardLayout) */
|
|
56
|
+
hideHeader?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ProfilePage component for authentication and user profile display
|
|
61
|
+
* Matches the todo app's ProfilePage styling and structure
|
|
62
|
+
*/
|
|
63
|
+
export function ProfilePage({
|
|
64
|
+
onBack,
|
|
65
|
+
enableGoogle,
|
|
66
|
+
enableGithub,
|
|
67
|
+
onAuthSuccess,
|
|
68
|
+
friendsEndpoint,
|
|
69
|
+
friendRequestsEndpoint,
|
|
70
|
+
userSearchEndpoint,
|
|
71
|
+
apiBaseUrl = "",
|
|
72
|
+
authMeEndpoint = "/auth/me",
|
|
73
|
+
logoutEndpoint = "/auth/logout",
|
|
74
|
+
className,
|
|
75
|
+
children,
|
|
76
|
+
hideHeader,
|
|
77
|
+
}: ProfilePageProps) {
|
|
78
|
+
const { signOut: authSignOut } = useAuth();
|
|
79
|
+
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
80
|
+
const [userData, setUserData] = useState<{
|
|
81
|
+
userId: string;
|
|
82
|
+
userName: string;
|
|
83
|
+
avatarUrl: string;
|
|
84
|
+
} | null>(null);
|
|
85
|
+
const [friends, setFriends] = useState<Friend[]>([]);
|
|
86
|
+
const [friendRequests, setFriendRequests] = useState<FriendRequest[]>([]);
|
|
87
|
+
|
|
88
|
+
const checkAuthStatus = async () => {
|
|
89
|
+
try {
|
|
90
|
+
const endpoint = `${apiBaseUrl}${authMeEndpoint}`;
|
|
91
|
+
const response = await fetch(endpoint, {
|
|
92
|
+
credentials: "include",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (response.ok) {
|
|
96
|
+
const user = (await response.json()) as {
|
|
97
|
+
userId: string | number;
|
|
98
|
+
userName: string;
|
|
99
|
+
avatarUrl?: string;
|
|
100
|
+
};
|
|
101
|
+
// Validate user data before treating as logged in
|
|
102
|
+
if (user.userId && user.userName) {
|
|
103
|
+
setUserData({
|
|
104
|
+
userId: String(user.userId),
|
|
105
|
+
userName: user.userName,
|
|
106
|
+
avatarUrl: user.avatarUrl ?? "",
|
|
107
|
+
});
|
|
108
|
+
setIsLoggedIn(true);
|
|
109
|
+
} else {
|
|
110
|
+
// Invalid user data, clear auth state
|
|
111
|
+
setUserData(null);
|
|
112
|
+
setIsLoggedIn(false);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// Not authenticated, clear auth state
|
|
116
|
+
setUserData(null);
|
|
117
|
+
setIsLoggedIn(false);
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error("Error checking auth status:", err);
|
|
121
|
+
// On error, clear auth state
|
|
122
|
+
setUserData(null);
|
|
123
|
+
setIsLoggedIn(false);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleLogout = async () => {
|
|
128
|
+
try {
|
|
129
|
+
const endpoint = `${apiBaseUrl}${logoutEndpoint}`;
|
|
130
|
+
await fetch(endpoint, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
credentials: "include",
|
|
133
|
+
});
|
|
134
|
+
authSignOut();
|
|
135
|
+
setUserData(null);
|
|
136
|
+
setIsLoggedIn(false);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error("Logout error:", err);
|
|
139
|
+
// Still clear local state even if server request fails
|
|
140
|
+
authSignOut();
|
|
141
|
+
setUserData(null);
|
|
142
|
+
setIsLoggedIn(false);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
interface FriendResponse {
|
|
147
|
+
friendId: string;
|
|
148
|
+
friend?: {
|
|
149
|
+
displayName?: string;
|
|
150
|
+
email?: string;
|
|
151
|
+
profilePicture?: string;
|
|
152
|
+
};
|
|
153
|
+
status?: string;
|
|
154
|
+
presence?: string | number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const loadFriends = async () => {
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch(friendsEndpoint ?? "", {
|
|
160
|
+
credentials: "include",
|
|
161
|
+
});
|
|
162
|
+
if (response.ok) {
|
|
163
|
+
const data = (await response.json()) as {
|
|
164
|
+
value?: FriendResponse[];
|
|
165
|
+
};
|
|
166
|
+
const friendsList: Friend[] = (data.value ?? []).map(
|
|
167
|
+
(f: FriendResponse) => ({
|
|
168
|
+
id: f.friendId,
|
|
169
|
+
displayName: f.friend?.displayName ?? "Unknown",
|
|
170
|
+
email: f.friend?.email,
|
|
171
|
+
profilePicture: f.friend?.profilePicture,
|
|
172
|
+
status:
|
|
173
|
+
f.status === "pending" || f.status === "accepted"
|
|
174
|
+
? f.status
|
|
175
|
+
: "accepted",
|
|
176
|
+
presence: (() => {
|
|
177
|
+
if (f.presence === undefined) return undefined;
|
|
178
|
+
if (typeof f.presence === "number") return f.presence;
|
|
179
|
+
const parsed = Number.parseFloat(f.presence);
|
|
180
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
181
|
+
})(),
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
setFriends(friendsList);
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error("Error loading friends:", error);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const loadFriendRequests = async () => {
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetch(friendRequestsEndpoint ?? "", {
|
|
194
|
+
credentials: "include",
|
|
195
|
+
});
|
|
196
|
+
if (response.ok) {
|
|
197
|
+
const data = (await response.json()) as {
|
|
198
|
+
value?: FriendRequest[];
|
|
199
|
+
};
|
|
200
|
+
setFriendRequests(data.value ?? []);
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.error("Error loading friend requests:", error);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Load friends and friend requests when logged in
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (isLoggedIn && userData?.userId) {
|
|
210
|
+
void loadFriends();
|
|
211
|
+
void loadFriendRequests();
|
|
212
|
+
}
|
|
213
|
+
/* eslint-disable react-hooks/exhaustive-deps */
|
|
214
|
+
}, [isLoggedIn, userData]);
|
|
215
|
+
/* eslint-enable react-hooks/exhaustive-deps */
|
|
216
|
+
|
|
217
|
+
const handleUserSelect = async (user: BaseUser) => {
|
|
218
|
+
try {
|
|
219
|
+
const response = await fetch(`${friendsEndpoint ?? ""}/${user.id}`, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
credentials: "include",
|
|
222
|
+
});
|
|
223
|
+
if (response.ok) {
|
|
224
|
+
// Reload friend requests to show the sent request
|
|
225
|
+
void loadFriendRequests();
|
|
226
|
+
} else {
|
|
227
|
+
const errorData = (await response.json().catch(() => ({}))) as {
|
|
228
|
+
error?: string;
|
|
229
|
+
};
|
|
230
|
+
alert(errorData.error ?? "Failed to send friend request");
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.error("Error sending friend request:", error);
|
|
234
|
+
alert("Failed to send friend request");
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const handleRemoveFriend = async (friendId: string) => {
|
|
239
|
+
try {
|
|
240
|
+
const response = await fetch(`${friendsEndpoint ?? ""}/${friendId}`, {
|
|
241
|
+
method: "DELETE",
|
|
242
|
+
credentials: "include",
|
|
243
|
+
});
|
|
244
|
+
if (response.ok) {
|
|
245
|
+
void loadFriends();
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
console.error("Error removing friend:", error);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const handleAcceptRequest = async (requestId: string) => {
|
|
253
|
+
try {
|
|
254
|
+
const response = await fetch(
|
|
255
|
+
`${friendRequestsEndpoint ?? ""}/${requestId}/accept`,
|
|
256
|
+
{
|
|
257
|
+
method: "PUT",
|
|
258
|
+
credentials: "include",
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
if (response.ok) {
|
|
262
|
+
void loadFriends();
|
|
263
|
+
void loadFriendRequests();
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
console.error("Error accepting friend request:", error);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const handleRejectRequest = async (requestId: string) => {
|
|
271
|
+
try {
|
|
272
|
+
const response = await fetch(
|
|
273
|
+
`${friendRequestsEndpoint ?? ""}/${requestId}/reject`,
|
|
274
|
+
{
|
|
275
|
+
method: "PUT",
|
|
276
|
+
credentials: "include",
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
if (response.ok) {
|
|
280
|
+
void loadFriendRequests();
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.error("Error rejecting friend request:", error);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const handleAuthSuccess = (user: AuthUser, provider: "google" | "github") => {
|
|
288
|
+
// Sync with backend after successful auth
|
|
289
|
+
void checkAuthStatus();
|
|
290
|
+
if (onAuthSuccess) {
|
|
291
|
+
onAuthSuccess(user, provider);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div className={`max-w-md mx-auto p-4 ${className ?? ""}`}>
|
|
297
|
+
{/* Header */}
|
|
298
|
+
{!hideHeader && (
|
|
299
|
+
<div className="flex items-center justify-between mb-6">
|
|
300
|
+
{onBack && (
|
|
301
|
+
<Button variant="ghost" onClick={onBack} className="p-2">
|
|
302
|
+
<svg
|
|
303
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
304
|
+
width="20"
|
|
305
|
+
height="20"
|
|
306
|
+
viewBox="0 0 24 24"
|
|
307
|
+
fill="none"
|
|
308
|
+
stroke="currentColor"
|
|
309
|
+
strokeWidth="2"
|
|
310
|
+
strokeLinecap="round"
|
|
311
|
+
strokeLinejoin="round"
|
|
312
|
+
>
|
|
313
|
+
<path d="m15 18-6-6 6-6" />
|
|
314
|
+
</svg>
|
|
315
|
+
</Button>
|
|
316
|
+
)}
|
|
317
|
+
<h1 className="text-2xl font-bold">Profile</h1>
|
|
318
|
+
<div className="w-10" /> {/* Spacer for centering */}
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{/* Authentication Section */}
|
|
323
|
+
{isLoggedIn && userData?.userId && userData.userName ? (
|
|
324
|
+
<Card className="p-6 flex flex-col items-center justify-center">
|
|
325
|
+
<div className="text-center w-full">
|
|
326
|
+
<Tooltip>
|
|
327
|
+
<TooltipTrigger asChild>
|
|
328
|
+
<img
|
|
329
|
+
src={userData.avatarUrl}
|
|
330
|
+
alt={userData.userName}
|
|
331
|
+
className="w-20 h-20 rounded-full mx-auto mb-4 cursor-pointer"
|
|
332
|
+
/>
|
|
333
|
+
</TooltipTrigger>
|
|
334
|
+
<TooltipContent>
|
|
335
|
+
<p>{userData.userName}</p>
|
|
336
|
+
{userData.userId && (
|
|
337
|
+
<p className="text-xs text-muted-foreground">
|
|
338
|
+
User ID: {userData.userId}
|
|
339
|
+
</p>
|
|
340
|
+
)}
|
|
341
|
+
</TooltipContent>
|
|
342
|
+
</Tooltip>
|
|
343
|
+
<h2 className="text-xl font-semibold mb-2">@{userData.userName}</h2>
|
|
344
|
+
<Button
|
|
345
|
+
onClick={() => {
|
|
346
|
+
void handleLogout();
|
|
347
|
+
}}
|
|
348
|
+
variant="outline"
|
|
349
|
+
>
|
|
350
|
+
Sign Out
|
|
351
|
+
</Button>
|
|
352
|
+
</div>
|
|
353
|
+
</Card>
|
|
354
|
+
) : (
|
|
355
|
+
<>
|
|
356
|
+
{/* GitHub Login Section */}
|
|
357
|
+
{enableGithub && (
|
|
358
|
+
<Card className="p-6">
|
|
359
|
+
<div className="text-center w-full">
|
|
360
|
+
<h2 className="text-xl font-semibold mb-2">GitHub Login</h2>
|
|
361
|
+
<p className="text-muted-foreground font-medium mb-4">
|
|
362
|
+
Connect your GitHub account to sync your data and collaborate.
|
|
363
|
+
</p>
|
|
364
|
+
<AuthSelector
|
|
365
|
+
enableGithub
|
|
366
|
+
enableGoogle={false}
|
|
367
|
+
onAuthSuccess={(
|
|
368
|
+
user: AuthUser,
|
|
369
|
+
provider: "google" | "github"
|
|
370
|
+
) => {
|
|
371
|
+
handleAuthSuccess(user, provider);
|
|
372
|
+
}}
|
|
373
|
+
/>
|
|
374
|
+
</div>
|
|
375
|
+
</Card>
|
|
376
|
+
)}
|
|
377
|
+
|
|
378
|
+
{/* Google Login Section */}
|
|
379
|
+
{enableGoogle && (
|
|
380
|
+
<Card className={`p-6 ${(enableGithub ?? "") ? "mt-4" : ""}`}>
|
|
381
|
+
<div className="text-center w-full">
|
|
382
|
+
<h2 className="text-xl font-semibold mb-2">Google Login</h2>
|
|
383
|
+
<p className="text-muted-foreground font-medium mb-4">
|
|
384
|
+
Connect your Google account to sync your data and collaborate.
|
|
385
|
+
</p>
|
|
386
|
+
<AuthSelector
|
|
387
|
+
enableGithub={false}
|
|
388
|
+
enableGoogle
|
|
389
|
+
onAuthSuccess={(
|
|
390
|
+
user: AuthUser,
|
|
391
|
+
provider: "google" | "github"
|
|
392
|
+
) => {
|
|
393
|
+
handleAuthSuccess(user, provider);
|
|
394
|
+
}}
|
|
395
|
+
/>
|
|
396
|
+
</div>
|
|
397
|
+
</Card>
|
|
398
|
+
)}
|
|
399
|
+
</>
|
|
400
|
+
)}
|
|
401
|
+
|
|
402
|
+
{/* Friends Management Section - Only for logged-in users */}
|
|
403
|
+
{isLoggedIn && userData?.userId && (
|
|
404
|
+
<Card className="p-6 mt-4">
|
|
405
|
+
<h3 className="text-lg font-semibold mb-4">Friends</h3>
|
|
406
|
+
|
|
407
|
+
{/* Friend Requests */}
|
|
408
|
+
<div className="mb-6">
|
|
409
|
+
<FriendRequestList
|
|
410
|
+
requests={friendRequests}
|
|
411
|
+
onAccept={(id) => {
|
|
412
|
+
void handleAcceptRequest(id);
|
|
413
|
+
}}
|
|
414
|
+
onReject={(id) => {
|
|
415
|
+
void handleRejectRequest(id);
|
|
416
|
+
}}
|
|
417
|
+
title="Friend Requests"
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
{/* Add Friend Search */}
|
|
422
|
+
<div className="mb-6">
|
|
423
|
+
<UserSearch
|
|
424
|
+
onUserSelect={(user) => {
|
|
425
|
+
void handleUserSelect(user);
|
|
426
|
+
}}
|
|
427
|
+
currentUserId={userData.userId}
|
|
428
|
+
searchEndpoint={userSearchEndpoint}
|
|
429
|
+
placeholder="Search for friends..."
|
|
430
|
+
label="Add Friend"
|
|
431
|
+
/>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
{/* Friends List */}
|
|
435
|
+
<div>
|
|
436
|
+
<FriendList
|
|
437
|
+
friends={friends}
|
|
438
|
+
onRemove={(id) => {
|
|
439
|
+
void handleRemoveFriend(id);
|
|
440
|
+
}}
|
|
441
|
+
showPresence
|
|
442
|
+
title="My Friends"
|
|
443
|
+
/>
|
|
444
|
+
</div>
|
|
445
|
+
</Card>
|
|
446
|
+
)}
|
|
447
|
+
|
|
448
|
+
{/* App-Specific Settings Section */}
|
|
449
|
+
{children && <div className="mt-4">{children}</div>}
|
|
450
|
+
</div>
|
|
451
|
+
);
|
|
452
|
+
}
|