From 193636726e8240f391ff0d4a6c080fac56380622 Mon Sep 17 00:00:00 2001 From: balog-b Date: Sun, 20 Sep 2026 22:33:21 +0200 Subject: [PATCH] feat: profile page, initals & adapted avatar --- .gitignore | 13 +- .../design-system/membership-card.tsx | 10 +- .../design-system/user-avatar.stories.tsx | 36 +-- .../components/design-system/user-avatar.tsx | 33 +-- full-stack/src/components/ui/avatar.tsx | 16 +- full-stack/src/routes/__root.tsx | 2 - full-stack/src/routes/user/profile.tsx | 278 ++++++++++++++++-- full-stack/src/styles.css | 45 ++- full-stack/src/utils/string.ts | 9 + 9 files changed, 363 insertions(+), 79 deletions(-) create mode 100644 full-stack/src/utils/string.ts diff --git a/.gitignore b/.gitignore index 4a929c9..4c30b82 100644 --- a/.gitignore +++ b/.gitignore @@ -151,4 +151,15 @@ dist # Vite files vite.config.js.timestamp-* vite.config.ts.timestamp-* -.vite/ \ No newline at end of file +.vite/ + +# JetBrains IDEA +.idea/* + +# OpenCode +.opencode/* +AGENTS.md + +# Playwright MCP +.playwright-mcp/* +/*.png diff --git a/full-stack/src/components/design-system/membership-card.tsx b/full-stack/src/components/design-system/membership-card.tsx index 07ceb70..8eaeea4 100644 --- a/full-stack/src/components/design-system/membership-card.tsx +++ b/full-stack/src/components/design-system/membership-card.tsx @@ -1,5 +1,6 @@ import { cva } from 'class-variance-authority'; import { cn } from '@/lib/utils'; +import { getInitials } from '@/utils/string'; import { Button } from '../ui/button'; import { Card, CardContent, CardHeader } from '../ui/card'; import { Detail, Subtitle, Text } from './typography'; @@ -72,13 +73,17 @@ export function MembershipCard({ variant, groupName, roles, + startDate = '2020 mar', endDate, + onOpen, }: { className?: string; variant: 'tier1' | 'tier2' | 'active' | 'alumni' | 'newbie'; groupName: string; roles: string; + startDate?: string; endDate?: string; + onOpen?: () => void; }) { const hasBackground = variant === 'tier1' || variant === 'tier2' || variant === 'active'; @@ -87,6 +92,7 @@ export function MembershipCard({ return ( {/* Group Avatar */}
- S + {getInitials(groupName)}
{/* Group name */} @@ -142,7 +148,7 @@ export function MembershipCard({
- {'2020 mar'} + {startDate} {endDate ? ( <>
diff --git a/full-stack/src/components/design-system/user-avatar.stories.tsx b/full-stack/src/components/design-system/user-avatar.stories.tsx index 1f5868a..05acad4 100644 --- a/full-stack/src/components/design-system/user-avatar.stories.tsx +++ b/full-stack/src/components/design-system/user-avatar.stories.tsx @@ -246,21 +246,21 @@ export const AllSizes: Story = { name: 'All Sizes', render: () => (
- + JD - + JD - + {/* Tier 1 row */}
- + T1 - + T1 - + T1
{/* Tier 2 row */}
- + T2 - + T2 - + T2
{/* Tier 3 row */}
- + T3 - + T3 - + T3
{/* Draft row */}
- + DR - + DR - + DR
{/* Inactive row */}
- + IN - + IN - + IN
diff --git a/full-stack/src/components/design-system/user-avatar.tsx b/full-stack/src/components/design-system/user-avatar.tsx index 08e6ffb..9ded8db 100644 --- a/full-stack/src/components/design-system/user-avatar.tsx +++ b/full-stack/src/components/design-system/user-avatar.tsx @@ -8,18 +8,19 @@ export type UserAvatarTier = | 'active' | 'inactive' | 'unknown'; -export type UserAvatarSize = 'sm' | 'default' | 'lg'; +export type AvatarSizeClass = + | 'avatar-small' + | 'avatar-default' + | 'avatar-large'; export interface UserAvatarProps extends ComponentProps { tier?: UserAvatarTier; - size?: UserAvatarSize; showBadge?: boolean; children?: React.ReactNode; } export function UserAvatar({ tier = 'unknown', - size = 'default', showBadge = true, className, children, @@ -29,24 +30,18 @@ export function UserAvatar({ {children} - {showBadge && - size !== 'sm' && - (tier === 'tier-1' || tier === 'tier-2') && ( - - )} + {showBadge && (tier === 'tier-1' || tier === 'tier-2') && ( + + )} ); } @@ -78,8 +73,6 @@ export function UserAvatarFallback({ className={cn( 'flex size-full items-center justify-center rounded-full', 'font-semibold text-lg', - 'data-[size=sm]:text-xs', - 'data-[size=lg]:text-3xl', className )} {...props} @@ -89,13 +82,7 @@ export function UserAvatarFallback({ ); } -function UserAvatarBadge({ - large, - tier, -}: { - large: boolean; - tier: 'tier-1' | 'tier-2'; -}) { +function UserAvatarBadge({ tier }: { tier: 'tier-1' | 'tier-2' }) { const BadgeIcon = tier === 'tier-2' ? KBCard : ABCard; return ( @@ -110,9 +97,7 @@ function UserAvatarBadge({ // Tier gradient background 'bg-linear-to-br', 'data-[tier=tier-1]:from-tier-1-start data-[tier=tier-1]:to-tier-1-end', - 'data-[tier=tier-2]:from-tier-2-start data-[tier=tier-2]:to-tier-2-end', - // Size-based scaling - large && 'origin-bottom-left scale-[2]' + 'data-[tier=tier-2]:from-tier-2-start data-[tier=tier-2]:to-tier-2-end' )} > diff --git a/full-stack/src/components/ui/avatar.tsx b/full-stack/src/components/ui/avatar.tsx index 75c377d..3c1f9e9 100644 --- a/full-stack/src/components/ui/avatar.tsx +++ b/full-stack/src/components/ui/avatar.tsx @@ -5,17 +5,13 @@ import { cn } from "@/lib/utils" function Avatar({ className, - size = "default", ...props -}: React.ComponentProps & { - size?: "default" | "sm" | "lg" -}) { +}: React.ComponentProps) { return ( ) { data-slot="avatar-badge" className={cn( "bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none", - "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", - "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", - "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className )} {...props} @@ -91,7 +85,7 @@ function AvatarGroupCount({ return (
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)} + className={cn("bg-muted text-muted-foreground rounded-full text-sm ring-background relative flex shrink-0 items-center justify-center ring-2", className)} {...props} /> ) diff --git a/full-stack/src/routes/__root.tsx b/full-stack/src/routes/__root.tsx index 68f3be7..fb1b474 100644 --- a/full-stack/src/routes/__root.tsx +++ b/full-stack/src/routes/__root.tsx @@ -5,7 +5,6 @@ import { Scripts, } from '@tanstack/react-router'; import { Devtools } from '@/devtools'; -import { Header } from '../components/header'; import appCss from '../styles.css?url'; export const Route = createRootRouteWithContext<{ @@ -43,7 +42,6 @@ function RootDocument({ children }: { children: React.ReactNode }) { -
{children} diff --git a/full-stack/src/routes/user/profile.tsx b/full-stack/src/routes/user/profile.tsx index fe1b792..e2f74aa 100644 --- a/full-stack/src/routes/user/profile.tsx +++ b/full-stack/src/routes/user/profile.tsx @@ -1,29 +1,267 @@ -import 'reflect-metadata'; import { createFileRoute } from '@tanstack/react-router'; +import { + ChevronDown, + Clock, + GraduationCap, + Home, + type LucideIcon, + Moon, + MoreVertical, + Search, + Send, + Sun, +} from 'lucide-react'; +import { useState } from 'react'; +import { MembershipCard } from '@/components/design-system/membership-card'; +import { Text } from '@/components/design-system/typography'; +import { + UserAvatar, + UserAvatarFallback, +} from '@/components/design-system/user-avatar'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; +import { getInitials } from '@/utils/string'; export const Route = createFileRoute('/user/profile')({ - component: UserProfileComponent, + component: UserProfilePage, }); -function UserProfileComponent() { - // Client-side component +type GroupVariant = 'tier1' | 'tier2' | 'active' | 'alumni' | 'newbie'; + +interface GroupData { + id: string; + variant: GroupVariant; + groupName: string; + roles: string; + startDate: string; + startUnit?: string; + endDate?: string; +} + +const MOCK_PROFILE = { + name: 'Jane Doe', + nickname: 'Jenny', + handle: '@realijanedo', +}; + +const MOCK_GROUPS: Array = [ + { + id: 'lovagrend-1', + variant: 'tier2', + groupName: 'Szent Schönherz Senior Lovagrend', + roles: 'körvezető, administrátor', + startDate: '2000 MAR', + }, + { + id: 'lovagrend-2', + variant: 'tier2', + groupName: 'Szent Schönherz Senior Lovagrend', + roles: 'körvezető, administrátor', + startDate: '2000 MAR', + }, + { + id: 'lovagrend-3', + variant: 'alumni', + groupName: 'Szent Schönherz Senior Lovagrend', + roles: 'körvezető, administrátor', + startDate: '2000 MAR', + }, + { + id: 'lovagrend-4', + variant: 'alumni', + groupName: 'Szent Schönherz Senior Lovagrend', + roles: 'körvezető, administrátor', + startDate: '2000 MAR', + }, + { + id: 'lovagrend-5', + variant: 'alumni', + groupName: 'Szent Schönherz Senior Lovagrend', + roles: 'körvezető, administrátor', + startDate: '2000 MAR', + }, + { + id: 'lovagrend-6', + variant: 'alumni', + groupName: 'Szent Schönherz Senior Lovagrend', + roles: 'alumni', + startDate: '2000 MAR', + endDate: '2025 JUN', + }, +]; + +const INFO_ITEMS: Array<{ icon: LucideIcon; value: string; label: string }> = [ + { icon: Home, value: 'SCH-1313', label: 'Residence' }, + { icon: Clock, value: '2021', label: 'Joined' }, + { icon: GraduationCap, value: '2025', label: 'Last Active' }, +]; + +function ContactButton({ + className, + onClick, +}: { + className?: string; + onClick: () => void; +}) { return ( -
-
-

User Profile

-

- This is an example route demonstrating NestJS-style dependency - injection in TanStack Start. -

-
-

Features:

-
    -
  • Type-safe dependency injection using tsyringe
  • -
  • Request-scoped Prisma service
  • -
  • Authentication and authorization middleware
  • -
  • Automatic resource cleanup after request
  • -
-
+ + ); +} + +function UserProfilePage() { + // TODO: wire behavior + function handleOpenContact() {} + function handleToggleHandleMenu() {} + function handleSearchSubmit() {} + function handleOpenOverflowMenu() {} + function handleOpenGroup(_group: GroupData) {} + + const [theme, setTheme] = useState<'dark' | 'light'>('dark'); + + return ( +
+
+ + +
+ +
+ + Groups + Activity + +
+ + + +
+ + +
+
+
+ + +
+ {MOCK_GROUPS.map((group) => ( + handleOpenGroup(group)} + /> + ))} +
+
+ +
Activity
+
+
+
); diff --git a/full-stack/src/styles.css b/full-stack/src/styles.css index e3c4deb..05b3c45 100644 --- a/full-stack/src/styles.css +++ b/full-stack/src/styles.css @@ -143,7 +143,7 @@ @media (min-width: 768px) { :root { --space-page: 1.5rem; /* 24px */ - --space-section: 1.25rem; /* 20px */ + --space-section: 2rem; /* 32px */ --space-card: 1rem; /* 16px */ --space-inline: 0.75rem; /* 12px */ } @@ -259,6 +259,49 @@ @apply duration-700; } +@utility avatar-small { + @apply size-10 ring-2; + [data-slot="avatar-fallback"] { + @apply text-xs; + } + [data-slot="user-avatar-badge"], + [data-slot="avatar-badge"] { + display: none; + } + [data-slot="avatar-group-count"] { + @apply size-6 [&>svg]:size-3; + } +} + +@utility avatar-default { + @apply size-30 ring-4; + [data-slot="avatar-fallback"] { + @apply text-lg; + } + [data-slot="avatar-badge"] { + @apply size-2.5 [&>svg]:size-2; + } + [data-slot="avatar-group-count"] { + @apply size-8 [&>svg]:size-4; + } +} + +@utility avatar-large { + @apply size-50 ring-6; + [data-slot="avatar-fallback"] { + @apply text-3xl; + } + [data-slot="user-avatar-badge"] { + @apply origin-bottom-left scale-2; + } + [data-slot="avatar-badge"] { + @apply size-3 [&>svg]:size-2; + } + [data-slot="avatar-group-count"] { + @apply size-10 [&>svg]:size-5; + } +} + @keyframes fadeIn { from { opacity: 0; diff --git a/full-stack/src/utils/string.ts b/full-stack/src/utils/string.ts new file mode 100644 index 0000000..1d67769 --- /dev/null +++ b/full-stack/src/utils/string.ts @@ -0,0 +1,9 @@ +export function getInitials(name: string): string { + const words = name.trim().split(/\s+/); + const firstWord = words[0] ?? ''; + const lastWord = words.at(-1) ?? ''; + if (words.length === 1) { + return firstWord.charAt(0).toUpperCase(); + } + return (firstWord.charAt(0) + lastWord.charAt(0)).toUpperCase(); +}