@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,135 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/utils";
|
|
5
|
+
|
|
6
|
+
interface ActivityAnimationProps {
|
|
7
|
+
/** Animation type: "pulse" for pulsing/spinner style, "particles" for particle motion */
|
|
8
|
+
type?: "pulse" | "particles";
|
|
9
|
+
/** Additional CSS classes to apply */
|
|
10
|
+
className?: string;
|
|
11
|
+
/** Size of the animation indicator */
|
|
12
|
+
size?: "sm" | "md" | "lg";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ActivityAnimation component displays animated activity indicators.
|
|
17
|
+
* Supports two animation types: pulse (spinner-style) and particles (motion-based).
|
|
18
|
+
* Useful for indicating active states, loading, or ongoing processes.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* import { ActivityAnimation } from "@shared/components/ux/ActivityAnimation"
|
|
23
|
+
* import { Button } from "@shared/components/ui/button"
|
|
24
|
+
*
|
|
25
|
+
* // Pulse animation (default)
|
|
26
|
+
* function LoadingButton() {
|
|
27
|
+
* return (
|
|
28
|
+
* <Button disabled>
|
|
29
|
+
* <ActivityAnimation type="pulse" size="sm" className="mr-2" />
|
|
30
|
+
* Processing...
|
|
31
|
+
* </Button>
|
|
32
|
+
* )
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* // Particles animation
|
|
36
|
+
* function ActiveIndicator() {
|
|
37
|
+
* return (
|
|
38
|
+
* <div className="flex items-center gap-2">
|
|
39
|
+
* <ActivityAnimation type="particles" size="md" />
|
|
40
|
+
* <span>Active session</span>
|
|
41
|
+
* </div>
|
|
42
|
+
* )
|
|
43
|
+
* }
|
|
44
|
+
*
|
|
45
|
+
* // Inline with text
|
|
46
|
+
* function TimerButton({ isRunning }: { isRunning: boolean }) {
|
|
47
|
+
* return (
|
|
48
|
+
* <Button variant={isRunning ? "destructive" : "default"}>
|
|
49
|
+
* {isRunning && (
|
|
50
|
+
* <ActivityAnimation type="pulse" size="sm" className="mr-1" />
|
|
51
|
+
* )}
|
|
52
|
+
* {isRunning ? "25:00" : "Start Timer"}
|
|
53
|
+
* </Button>
|
|
54
|
+
* )
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function ActivityAnimation({
|
|
59
|
+
type,
|
|
60
|
+
className,
|
|
61
|
+
size,
|
|
62
|
+
}: ActivityAnimationProps) {
|
|
63
|
+
const sizeClasses = {
|
|
64
|
+
sm: "w-3 h-3",
|
|
65
|
+
md: "w-4 h-4",
|
|
66
|
+
lg: "w-5 h-5",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (type === "pulse") {
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={cn(
|
|
73
|
+
"relative inline-flex items-center justify-center",
|
|
74
|
+
className
|
|
75
|
+
)}
|
|
76
|
+
aria-label="Activity indicator"
|
|
77
|
+
>
|
|
78
|
+
<div
|
|
79
|
+
className={cn(
|
|
80
|
+
"rounded-full bg-current opacity-75",
|
|
81
|
+
sizeClasses[size ?? "md"],
|
|
82
|
+
"animate-pulse"
|
|
83
|
+
)}
|
|
84
|
+
/>
|
|
85
|
+
<div
|
|
86
|
+
className={cn(
|
|
87
|
+
"absolute rounded-full bg-current opacity-40",
|
|
88
|
+
sizeClasses[size ?? "md"],
|
|
89
|
+
"animate-ping"
|
|
90
|
+
)}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Particles animation
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
className={cn(
|
|
100
|
+
"relative inline-flex items-center justify-center",
|
|
101
|
+
className
|
|
102
|
+
)}
|
|
103
|
+
aria-label="Activity indicator"
|
|
104
|
+
style={{
|
|
105
|
+
width: size === "sm" ? "12px" : size === "md" ? "16px" : "20px",
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{[0, 1, 2].map((i) => {
|
|
109
|
+
const offset = size === "sm" ? "-4px" : size === "md" ? "-6px" : "-8px";
|
|
110
|
+
const rotation = i * 120;
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
key={i}
|
|
114
|
+
className={cn(
|
|
115
|
+
"absolute rounded-full bg-current opacity-60",
|
|
116
|
+
size === "sm"
|
|
117
|
+
? "w-1 h-1"
|
|
118
|
+
: size === "md"
|
|
119
|
+
? "w-1.5 h-1.5"
|
|
120
|
+
: "w-2 h-2"
|
|
121
|
+
)}
|
|
122
|
+
style={{
|
|
123
|
+
animation: `particle-float-opacity 2s ease-in-out infinite`,
|
|
124
|
+
animationDelay: `${String(i * 0.3)}s`,
|
|
125
|
+
transform: `rotate(${String(rotation)}deg) translateY(${offset})`,
|
|
126
|
+
transformOrigin: "center",
|
|
127
|
+
}}
|
|
128
|
+
data-particle-index={i}
|
|
129
|
+
data-rotation={rotation}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
interface EmptyStateProps {
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* EmptyState component for displaying empty state messages
|
|
12
|
+
*/
|
|
13
|
+
export function EmptyState({ title, description, className }: EmptyStateProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className={`text-center py-12 px-4 text-muted-foreground ${className ?? ""}`}
|
|
17
|
+
>
|
|
18
|
+
<p className="mb-2">{title}</p>
|
|
19
|
+
{description && <p className="text-sm">{description}</p>}
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** @jsxImportSource react */
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { MessageSquare, Send } from "lucide-react";
|
|
7
|
+
import { Button } from "../ui/button.tsx";
|
|
8
|
+
import { Textarea } from "../ui/textarea.tsx";
|
|
9
|
+
import { Label } from "../ui/label.tsx";
|
|
10
|
+
import {
|
|
11
|
+
Drawer,
|
|
12
|
+
DrawerClose,
|
|
13
|
+
DrawerContent,
|
|
14
|
+
DrawerDescription,
|
|
15
|
+
DrawerFooter,
|
|
16
|
+
DrawerHeader,
|
|
17
|
+
DrawerTitle,
|
|
18
|
+
} from "../ui/drawer.tsx";
|
|
19
|
+
import { toast } from "../ui/sonner-api.ts";
|
|
20
|
+
|
|
21
|
+
interface FeedbackDrawerProps {
|
|
22
|
+
isOpen: boolean;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
onSubmit: (feedback: string) => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function FeedbackDrawer({
|
|
28
|
+
isOpen,
|
|
29
|
+
onClose,
|
|
30
|
+
onSubmit,
|
|
31
|
+
}: FeedbackDrawerProps) {
|
|
32
|
+
const [feedback, setFeedback] = useState("");
|
|
33
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
34
|
+
|
|
35
|
+
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
if (!feedback.trim() || feedback.trim().length < 10) return;
|
|
38
|
+
|
|
39
|
+
setIsSubmitting(true);
|
|
40
|
+
try {
|
|
41
|
+
await onSubmit(feedback.trim());
|
|
42
|
+
setFeedback("");
|
|
43
|
+
toast("Feedback Submitted", {
|
|
44
|
+
description: "Thank you for your feedback! We appreciate your input.",
|
|
45
|
+
});
|
|
46
|
+
onClose();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error("Failed to submit feedback:", error);
|
|
49
|
+
toast.error("Failed to Submit Feedback", {
|
|
50
|
+
description:
|
|
51
|
+
"There was an error submitting your feedback. Please try again.",
|
|
52
|
+
});
|
|
53
|
+
} finally {
|
|
54
|
+
setIsSubmitting(false);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleClose = () => {
|
|
59
|
+
if (!isSubmitting) {
|
|
60
|
+
setFeedback("");
|
|
61
|
+
onClose();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Drawer open={isOpen} onOpenChange={handleClose}>
|
|
67
|
+
<DrawerContent>
|
|
68
|
+
<div className="mx-auto w-full max-w-md">
|
|
69
|
+
<DrawerHeader>
|
|
70
|
+
<DrawerTitle className="flex items-center gap-2">
|
|
71
|
+
<MessageSquare className="h-5 w-5" />
|
|
72
|
+
Send Feedback
|
|
73
|
+
</DrawerTitle>
|
|
74
|
+
<DrawerDescription>
|
|
75
|
+
Help us improve by sharing your thoughts and suggestions
|
|
76
|
+
</DrawerDescription>
|
|
77
|
+
</DrawerHeader>
|
|
78
|
+
|
|
79
|
+
<form
|
|
80
|
+
onSubmit={(e) => {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
void handleSubmit(e);
|
|
83
|
+
}}
|
|
84
|
+
className="p-4 pb-0 space-y-4"
|
|
85
|
+
>
|
|
86
|
+
<div className="space-y-2">
|
|
87
|
+
<Label htmlFor="feedback">Your Feedback</Label>
|
|
88
|
+
<Textarea
|
|
89
|
+
id="feedback"
|
|
90
|
+
value={feedback}
|
|
91
|
+
onChange={(e) => {
|
|
92
|
+
setFeedback(e.target.value);
|
|
93
|
+
}}
|
|
94
|
+
placeholder="Tell us what you think, what features you'd like to see, or report any issues..."
|
|
95
|
+
rows={6}
|
|
96
|
+
className="resize-none"
|
|
97
|
+
disabled={isSubmitting}
|
|
98
|
+
maxLength={1000}
|
|
99
|
+
required
|
|
100
|
+
/>
|
|
101
|
+
<p className="text-xs text-muted-foreground">
|
|
102
|
+
{feedback.length}/1000 characters (minimum 10 characters)
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
<DrawerFooter>
|
|
106
|
+
<div className="flex gap-2">
|
|
107
|
+
<DrawerClose asChild>
|
|
108
|
+
<Button
|
|
109
|
+
type="button"
|
|
110
|
+
variant="outline"
|
|
111
|
+
disabled={isSubmitting}
|
|
112
|
+
>
|
|
113
|
+
Cancel
|
|
114
|
+
</Button>
|
|
115
|
+
</DrawerClose>
|
|
116
|
+
<Button
|
|
117
|
+
type="submit"
|
|
118
|
+
disabled={
|
|
119
|
+
!feedback.trim() ||
|
|
120
|
+
feedback.trim().length < 10 ||
|
|
121
|
+
isSubmitting
|
|
122
|
+
}
|
|
123
|
+
className="flex-1"
|
|
124
|
+
>
|
|
125
|
+
{isSubmitting ? (
|
|
126
|
+
"Sending..."
|
|
127
|
+
) : (
|
|
128
|
+
<>
|
|
129
|
+
<Send className="h-4 w-4 mr-2" />
|
|
130
|
+
Send Feedback
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
</DrawerFooter>
|
|
136
|
+
</form>
|
|
137
|
+
</div>
|
|
138
|
+
</DrawerContent>
|
|
139
|
+
</Drawer>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Copyright 2026 Chesapeake Computing
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/utils";
|
|
5
|
+
|
|
6
|
+
interface FireAnimationProps {
|
|
7
|
+
/** Productivity score used to determine fire intensity (0-3: ember, 4-7: small, 8-12: medium, 13+: large) */
|
|
8
|
+
score: number;
|
|
9
|
+
/** Additional CSS classes to apply */
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type FireIntensity = "ember" | "small" | "medium" | "large";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* FireAnimation component displays an animated fire visualization based on a productivity score.
|
|
17
|
+
* The fire intensity scales with the score, providing visual feedback for achievement levels.
|
|
18
|
+
* Uses SVG animations for smooth, performant rendering.
|
|
19
|
+
*
|
|
20
|
+
* Score tiers:
|
|
21
|
+
* - 0-3: Ember (minimal fire, 2 flames)
|
|
22
|
+
* - 4-7: Small Fire (moderate fire, 3 flames)
|
|
23
|
+
* - 8-12: Medium Fire (good fire, 4 flames)
|
|
24
|
+
* - 13+: Large Fire (impressive fire, 5 flames)
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```tsx
|
|
28
|
+
* import { FireAnimation } from "@shared/components/ux/FireAnimation"
|
|
29
|
+
* import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@shared/components/ui/dialog"
|
|
30
|
+
*
|
|
31
|
+
* // In a completion modal
|
|
32
|
+
* function SessionCompleteModal({ score }: { score: number }) {
|
|
33
|
+
* return (
|
|
34
|
+
* <Dialog open={true}>
|
|
35
|
+
* <DialogContent>
|
|
36
|
+
* <DialogHeader>
|
|
37
|
+
* <DialogTitle>Session Complete!</DialogTitle>
|
|
38
|
+
* </DialogHeader>
|
|
39
|
+
* <div className="flex flex-col items-center py-4">
|
|
40
|
+
* <FireAnimation score={score} className="text-orange-500" />
|
|
41
|
+
* <p className="mt-2 text-sm">Your productivity score: {score}</p>
|
|
42
|
+
* </div>
|
|
43
|
+
* </DialogContent>
|
|
44
|
+
* </Dialog>
|
|
45
|
+
* )
|
|
46
|
+
* }
|
|
47
|
+
*
|
|
48
|
+
* // Calculate score and display
|
|
49
|
+
* function ProductivityDisplay({ tasksCompleted, tasksCreated, keystrokes }: Props) {
|
|
50
|
+
* const score = tasksCompleted * 2 + tasksCreated * 1 + Math.floor(keystrokes / 100)
|
|
51
|
+
*
|
|
52
|
+
* return (
|
|
53
|
+
* <div className="text-center">
|
|
54
|
+
* <FireAnimation score={score} className="text-red-500 dark:text-orange-400" />
|
|
55
|
+
* <p className="mt-2 text-lg font-bold">Score: {score}</p>
|
|
56
|
+
* </div>
|
|
57
|
+
* )
|
|
58
|
+
* }
|
|
59
|
+
*
|
|
60
|
+
* // With custom styling
|
|
61
|
+
* function CustomFireDisplay({ score }: { score: number }) {
|
|
62
|
+
* return (
|
|
63
|
+
* <div className="bg-gradient-to-b from-transparent to-orange-50 dark:to-orange-950 p-8 rounded-lg">
|
|
64
|
+
* <FireAnimation
|
|
65
|
+
* score={score}
|
|
66
|
+
* className="text-orange-600 dark:text-orange-400"
|
|
67
|
+
* />
|
|
68
|
+
* </div>
|
|
69
|
+
* )
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function FireAnimation({ score, className }: FireAnimationProps) {
|
|
74
|
+
const getIntensity = (score: number): FireIntensity => {
|
|
75
|
+
if (score <= 3) return "ember";
|
|
76
|
+
if (score <= 7) return "small";
|
|
77
|
+
if (score <= 12) return "medium";
|
|
78
|
+
return "large";
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const intensity = getIntensity(score);
|
|
82
|
+
|
|
83
|
+
const intensityConfig = {
|
|
84
|
+
ember: {
|
|
85
|
+
flames: 2,
|
|
86
|
+
height: "h-8",
|
|
87
|
+
opacity: "opacity-40",
|
|
88
|
+
scale: "scale-75",
|
|
89
|
+
},
|
|
90
|
+
small: {
|
|
91
|
+
flames: 3,
|
|
92
|
+
height: "h-12",
|
|
93
|
+
opacity: "opacity-60",
|
|
94
|
+
scale: "scale-90",
|
|
95
|
+
},
|
|
96
|
+
medium: {
|
|
97
|
+
flames: 4,
|
|
98
|
+
height: "h-16",
|
|
99
|
+
opacity: "opacity-80",
|
|
100
|
+
scale: "scale-100",
|
|
101
|
+
},
|
|
102
|
+
large: {
|
|
103
|
+
flames: 5,
|
|
104
|
+
height: "h-20",
|
|
105
|
+
opacity: "opacity-100",
|
|
106
|
+
scale: "scale-110",
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const config = intensityConfig[intensity];
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
className={cn(
|
|
115
|
+
"relative inline-flex items-end justify-center",
|
|
116
|
+
config.height,
|
|
117
|
+
className
|
|
118
|
+
)}
|
|
119
|
+
aria-label={`Fire animation: ${intensity} intensity (score: ${String(score)})`}
|
|
120
|
+
>
|
|
121
|
+
<svg
|
|
122
|
+
viewBox="0 0 100 100"
|
|
123
|
+
className={cn("w-full h-full", config.scale)}
|
|
124
|
+
preserveAspectRatio="xMidYMid meet"
|
|
125
|
+
>
|
|
126
|
+
{Array.from({ length: config.flames }).map((_, i) => {
|
|
127
|
+
const delay = i * 0.15;
|
|
128
|
+
const offset = (i - (config.flames - 1) / 2) * 20;
|
|
129
|
+
const baseHeight = 30 + (i % 2) * 10;
|
|
130
|
+
const baseWidth = 15 + (i % 2) * 5;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<g key={i} transform={`translate(${String(50 + offset)}, 80)`}>
|
|
134
|
+
<path
|
|
135
|
+
d={`M 0 0 Q ${String(baseWidth / 2)} -${String(baseHeight * 0.3)} ${String(
|
|
136
|
+
baseWidth / 2
|
|
137
|
+
)} -${String(baseHeight * 0.6)} T ${String(baseWidth / 2)} -${String(
|
|
138
|
+
baseHeight
|
|
139
|
+
)} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.9)} ${String(
|
|
140
|
+
baseWidth / 2
|
|
141
|
+
)} -${String(baseHeight * 0.7)} T 0 0`}
|
|
142
|
+
fill="currentColor"
|
|
143
|
+
className={cn(config.opacity, "animate-fire-flicker")}
|
|
144
|
+
style={{
|
|
145
|
+
animationDelay: `${String(delay)}s`,
|
|
146
|
+
transformOrigin: `${String(baseWidth / 2)}px ${String(baseHeight)}px`,
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<animate
|
|
150
|
+
attributeName="d"
|
|
151
|
+
values={`M 0 0 Q ${String(baseWidth / 2)} -${String(baseHeight * 0.3)} ${String(
|
|
152
|
+
baseWidth / 2
|
|
153
|
+
)} -${String(baseHeight * 0.6)} T ${String(baseWidth / 2)} -${String(
|
|
154
|
+
baseHeight
|
|
155
|
+
)} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.9)} ${String(
|
|
156
|
+
baseWidth / 2
|
|
157
|
+
)} -${String(baseHeight * 0.7)} T 0 0;
|
|
158
|
+
M 0 0 Q ${String(baseWidth / 3)} -${String(baseHeight * 0.35)} ${String(
|
|
159
|
+
baseWidth / 2
|
|
160
|
+
)} -${String(baseHeight * 0.65)} T ${String(baseWidth / 2)} -${String(
|
|
161
|
+
baseHeight
|
|
162
|
+
)} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.85)} ${String(
|
|
163
|
+
baseWidth / 2
|
|
164
|
+
)} -${String(baseHeight * 0.75)} T 0 0;
|
|
165
|
+
M 0 0 Q ${String(baseWidth / 2.5)} -${String(baseHeight * 0.32)} ${String(
|
|
166
|
+
baseWidth / 2
|
|
167
|
+
)} -${String(baseHeight * 0.58)} T ${String(baseWidth / 2)} -${String(
|
|
168
|
+
baseHeight
|
|
169
|
+
)} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.92)} ${String(
|
|
170
|
+
baseWidth / 2
|
|
171
|
+
)} -${String(baseHeight * 0.68)} T 0 0;
|
|
172
|
+
M 0 0 Q ${String(baseWidth / 2)} -${String(baseHeight * 0.3)} ${String(
|
|
173
|
+
baseWidth / 2
|
|
174
|
+
)} -${String(baseHeight * 0.6)} T ${String(baseWidth / 2)} -${String(
|
|
175
|
+
baseHeight
|
|
176
|
+
)} Q ${String(baseWidth / 2)} -${String(baseHeight * 0.9)} ${String(
|
|
177
|
+
baseWidth / 2
|
|
178
|
+
)} -${String(baseHeight * 0.7)} T 0 0`}
|
|
179
|
+
dur="1.5s"
|
|
180
|
+
repeatCount="indefinite"
|
|
181
|
+
/>
|
|
182
|
+
</path>
|
|
183
|
+
</g>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</svg>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.tsx";
|
|
8
|
+
import type { BaseUser } from "../../types/user.ts";
|
|
9
|
+
|
|
10
|
+
export interface Friend extends BaseUser {
|
|
11
|
+
status: "pending" | "accepted";
|
|
12
|
+
presence?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FriendListProps {
|
|
16
|
+
friends: Friend[];
|
|
17
|
+
onRemove: (friendId: string) => void;
|
|
18
|
+
showPresence?: boolean;
|
|
19
|
+
emptyMessage?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function FriendList({
|
|
24
|
+
friends,
|
|
25
|
+
onRemove,
|
|
26
|
+
showPresence,
|
|
27
|
+
emptyMessage,
|
|
28
|
+
title,
|
|
29
|
+
}: FriendListProps) {
|
|
30
|
+
if (friends.length === 0) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="space-y-2">
|
|
33
|
+
{title && <h3 className="font-medium">{title}</h3>}
|
|
34
|
+
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="space-y-2">
|
|
41
|
+
{title && <h3 className="font-medium">{title}</h3>}
|
|
42
|
+
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
|
43
|
+
{friends.map((friend) => (
|
|
44
|
+
<div
|
|
45
|
+
key={friend.id}
|
|
46
|
+
className="flex items-center justify-between p-3 border rounded-lg"
|
|
47
|
+
>
|
|
48
|
+
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
49
|
+
{friend.profilePicture && (
|
|
50
|
+
<div className="relative flex-shrink-0">
|
|
51
|
+
<Tooltip>
|
|
52
|
+
<TooltipTrigger asChild>
|
|
53
|
+
<img
|
|
54
|
+
src={friend.profilePicture}
|
|
55
|
+
alt={friend.displayName}
|
|
56
|
+
className="w-10 h-10 rounded-full object-cover"
|
|
57
|
+
/>
|
|
58
|
+
</TooltipTrigger>
|
|
59
|
+
<TooltipContent>
|
|
60
|
+
<p>{friend.displayName}</p>
|
|
61
|
+
{friend.email && (
|
|
62
|
+
<p className="text-xs text-muted-foreground">
|
|
63
|
+
{friend.email}
|
|
64
|
+
</p>
|
|
65
|
+
)}
|
|
66
|
+
{friend.status === "pending" && (
|
|
67
|
+
<p className="text-xs text-yellow-600">Pending</p>
|
|
68
|
+
)}
|
|
69
|
+
</TooltipContent>
|
|
70
|
+
</Tooltip>
|
|
71
|
+
{showPresence && friend.presence !== undefined && (
|
|
72
|
+
<div
|
|
73
|
+
className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-background ${
|
|
74
|
+
friend.presence > 0 ? "bg-green-500" : "bg-gray-400"
|
|
75
|
+
}`}
|
|
76
|
+
title={friend.presence > 0 ? "Online" : "Offline"}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
<div className="flex-1 min-w-0">
|
|
82
|
+
<div className="font-medium truncate">{friend.displayName}</div>
|
|
83
|
+
{friend.email && (
|
|
84
|
+
<div className="text-sm text-muted-foreground truncate">
|
|
85
|
+
{friend.email}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
{friend.status === "pending" && (
|
|
89
|
+
<div className="text-xs text-yellow-600 mt-1">Pending</div>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="ml-4 flex-shrink-0">
|
|
94
|
+
<Button
|
|
95
|
+
type="button"
|
|
96
|
+
variant="destructive"
|
|
97
|
+
size="sm"
|
|
98
|
+
onClick={() => {
|
|
99
|
+
onRemove(friend.id);
|
|
100
|
+
}}
|
|
101
|
+
className="h-8 w-8 p-0"
|
|
102
|
+
>
|
|
103
|
+
<X className="h-4 w-4" />
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|