Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client"

import Link from "next/link"
import { useEffect } from "react"
import { AlertTriangle, Home, RefreshCcw } from "lucide-react"
import { getCurrentLocale, translate } from "@/shared/i18n/translate"
Expand Down Expand Up @@ -73,13 +74,13 @@ export default function GlobalError({ error, reset }: GlobalErrorProps) {
<RefreshCcw className="mr-2 h-4 w-4" />
{translate("common.retry")}
</button>
<a
<Link
href="/"
className="inline-flex items-center justify-center rounded-2xl h-12 px-6 font-medium bg-blue-600 text-white hover:bg-blue-700 active:scale-[0.98] transition-all"
>
<Home className="mr-2 h-4 w-4" />
{translate("common.go-home")}
</a>
</Link>
</div>
</div>
</div>
Expand Down
13 changes: 8 additions & 5 deletions src/app/users/[username]/user-profile-client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { useParams, useRouter } from "next/navigation"
import { useRouter } from "next/navigation"
import { motion } from "framer-motion"
import {
RefreshCcw,
Expand All @@ -22,12 +22,12 @@ import { TiltCard } from "@/shared/components/ui/tilt-card"
import { OptimizedAvatar } from "@/shared/components/optimized-avatar"
import { Button } from "@/shared/components/button"
import { Skeleton } from "@/shared/components/skeleton"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/card"
import { Card, CardContent, CardTitle, CardDescription } from "@/shared/components/card"
import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
import { getErrorMessage } from "@/shared/lib/api-client"
import { useEffect, useState } from "react"
import { TIER_STYLES, getTierStyle } from "@/shared/constants/tier-styles"
import { TIER_STYLES } from "@/shared/constants/tier-styles"
import { useI18n } from "@/shared/providers/locale-provider"

interface UserProfileClientProps {
Expand All @@ -45,8 +45,11 @@ export function UserProfileClient({ username }: UserProfileClientProps) {

useEffect(() => {
if (user) {
setDisplayPercentile(0)
setTimeout(() => setDisplayPercentile(user.percentile), 100)
const timeoutId = window.setTimeout(() => {
setDisplayPercentile(user.percentile)
}, 100)

return () => window.clearTimeout(timeoutId)
}
}, [user])

Expand Down
24 changes: 11 additions & 13 deletions src/features/auth/store/auth-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useSyncExternalStore } from 'react';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { User } from '@/shared/types/api';
Expand All @@ -24,17 +24,15 @@ export const useAuthStore = create<AuthState>()(
)
);

export const useAuthHydrated = () => {
const [hydrated, setHydrated] = useState(false);

useEffect(() => {
if (useAuthStore.persist?.hasHydrated?.()) {
setHydrated(true);
return;
}
const unsub = useAuthStore.persist?.onFinishHydration?.(() => setHydrated(true));
return () => unsub?.();
}, []);
const subscribeToAuthHydration = (onStoreChange: () => void) => {
const unsubscribe = useAuthStore.persist?.onFinishHydration?.(() => onStoreChange());
return () => unsubscribe?.();
};

return hydrated;
export const useAuthHydrated = () => {
return useSyncExternalStore(
subscribeToAuthHydration,
() => useAuthStore.persist?.hasHydrated?.() ?? false,
() => false
);
};
26 changes: 10 additions & 16 deletions src/features/home/components/hero-section.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { useState, useEffect, useRef, useCallback } from "react"
import { useState, useRef, useCallback } from "react"
import { motion, AnimatePresence, useScroll, useTransform } from "framer-motion"
import { useRouter } from "next/navigation"
import { Search, History, X, BookOpen, TrendingUp } from "lucide-react"
Expand All @@ -17,6 +17,7 @@ import { LiveTicker } from "@/shared/components/ui/live-ticker"
import { toast } from "sonner"
import { useI18n } from "@/shared/providers/locale-provider"
import { localizePathname } from "@/shared/i18n/config"
import { useHasMounted } from "@/shared/hooks/use-has-mounted"

export function HeroSection() {
const { t, locale } = useI18n()
Expand All @@ -25,10 +26,10 @@ export function HeroSection() {
const [open, setOpen] = useState(false)
const [isFocused, setIsFocused] = useState(false)
const [query, setQuery] = useState("")
const [mounted, setMounted] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const sectionRef = useRef<HTMLElement>(null)
const mounted = useHasMounted()
const isMobile = useIsMobile()
const prefersReducedMotion = useReducedMotion()

Expand All @@ -54,9 +55,8 @@ export function HeroSection() {
? t("home.search.placeholder.mobile", { example: placeholderText })
: t("home.search.placeholder.desktop", { example: placeholderText })

useEffect(() => {
setMounted(true)
}, [])
const selectedSearch = selectedIndex >= 0 ? recentSearches[selectedIndex] ?? null : null
const activeQuery = selectedSearch ?? query

const handleFocus = useCallback(() => {
setOpen(true)
Expand Down Expand Up @@ -92,7 +92,7 @@ export function HeroSection() {

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!open) {
if (e.key === 'Enter') handleSearch(query)
if (e.key === 'Enter') handleSearch(activeQuery)
return
}
switch (e.key) {
Expand All @@ -107,7 +107,7 @@ export function HeroSection() {
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0) handleSearch(recentSearches[selectedIndex])
else handleSearch(query)
else handleSearch(activeQuery)
break
case 'Escape':
setOpen(false)
Expand All @@ -116,13 +116,7 @@ export function HeroSection() {
inputRef.current?.blur()
break
}
}, [open, query, recentSearches, selectedIndex, handleSearch])

useEffect(() => {
if (selectedIndex >= 0 && recentSearches[selectedIndex]) {
setQuery(recentSearches[selectedIndex])
}
}, [selectedIndex, recentSearches])
}, [activeQuery, open, recentSearches, selectedIndex, handleSearch])

return (
<section
Expand Down Expand Up @@ -191,7 +185,7 @@ export function HeroSection() {
ref={inputRef}
className="h-14 sm:h-16 w-full bg-transparent px-2 text-base sm:text-lg font-medium outline-none placeholder:text-muted-foreground/40 font-sans"
placeholder={placeholder}
value={query}
value={activeQuery}
onChange={(e) => {
setQuery(e.target.value)
setSelectedIndex(-1)
Expand All @@ -215,7 +209,7 @@ export function HeroSection() {
<Button
size="lg"
className="h-10 sm:h-12 rounded-xl px-4 sm:px-6 text-sm sm:text-base font-bold bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg"
onClick={() => handleSearch(query)}
onClick={() => handleSearch(activeQuery)}
>
{t("home.search.submit")}
</Button>
Expand Down
4 changes: 2 additions & 2 deletions src/features/ranking/api/ranking-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const getRankingList = cache(async (page: number, tier?: Tier): Promise<R
params.append("tier", tier)
}

return apiClient.get<any, RankingListResponse>(`/ranking?${params.toString()}`)
return apiClient.get<void, RankingListResponse>(`/ranking?${params.toString()}`)
})

export const useRankingList = (page: number, tier?: Tier) => {
Expand All @@ -20,4 +20,4 @@ export const useRankingList = (page: number, tier?: Tier) => {
queryFn: () => getRankingList(page, tier),
placeholderData: (previousData) => previousData,
})
}
}
78 changes: 41 additions & 37 deletions src/features/user/components/badge-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/card"
import { Button } from "@/shared/components/button"
import { Check, Copy, Link2, Code2, ExternalLink } from "lucide-react"
import { Check, Copy, Link2, Code2, ExternalLink, type LucideIcon } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
import { useI18n } from "@/shared/providers/locale-provider"
Expand All @@ -15,6 +15,36 @@ interface BadgeGeneratorProps {
}

type CopyType = "markdown" | "html" | "link" | null
type BadgeCopyType = Exclude<CopyType, null>

interface BadgeCopyButtonProps {
copied: CopyType
icon: LucideIcon
label: string
onCopy: () => void
type: BadgeCopyType
}

function BadgeCopyButton({ copied, icon: Icon, label, onCopy, type }: BadgeCopyButtonProps) {
return (
<Button
onClick={onCopy}
variant="ghost"
className={cn(
"h-10 px-4 rounded-xl font-medium text-sm transition-all duration-200",
"bg-secondary/50 hover:bg-secondary border border-transparent",
copied === type && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
)}
>
{copied === type ? (
<Check className="w-4 h-4 mr-2" />
) : (
<Icon className="w-4 h-4 mr-2" />
)}
{label}
</Button>
)
}

export function BadgeGenerator({ nodeId, username }: BadgeGeneratorProps) {
const { t, locale } = useI18n()
Expand All @@ -27,7 +57,7 @@ export function BadgeGenerator({ nodeId, username }: BadgeGeneratorProps) {
const markdownCode = `[![Git Ranker](${badgeUrl})](${profileUrl})`
const htmlCode = `<a href="${profileUrl}"><img src="${badgeUrl}" alt="Git Ranker Badge" /></a>`

const handleCopy = async (text: string, type: CopyType) => {
const handleCopy = async (text: string, type: BadgeCopyType) => {
await navigator.clipboard.writeText(text)
setCopied(type)
toast.success(
Expand All @@ -38,35 +68,6 @@ export function BadgeGenerator({ nodeId, username }: BadgeGeneratorProps) {
setTimeout(() => setCopied(null), 2000)
}

const CopyButton = ({
type,
text,
icon: Icon,
label
}: {
type: CopyType
text: string
icon: React.ElementType
label: string
}) => (
<Button
onClick={() => handleCopy(text, type)}
variant="ghost"
className={cn(
"h-10 px-4 rounded-xl font-medium text-sm transition-all duration-200",
"bg-secondary/50 hover:bg-secondary border border-transparent",
copied === type && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
)}
>
{copied === type ? (
<Check className="w-4 h-4 mr-2" />
) : (
<Icon className="w-4 h-4 mr-2" />
)}
{label}
</Button>
)

return (
<Card className="rounded-[2rem] sm:rounded-[2.5rem] border-0 bg-white/60 dark:bg-black/20 backdrop-blur-xl shadow-sm overflow-hidden">
<CardHeader className="pb-4 px-5 sm:px-8 pt-6 sm:pt-8">
Expand Down Expand Up @@ -104,23 +105,26 @@ export function BadgeGenerator({ nodeId, username }: BadgeGeneratorProps) {

{/* Copy Buttons */}
<div className="flex flex-wrap gap-2">
<CopyButton
<BadgeCopyButton
copied={copied}
type="markdown"
text={markdownCode}
icon={Copy}
label="Markdown"
onCopy={() => handleCopy(markdownCode, "markdown")}
/>
<CopyButton
<BadgeCopyButton
copied={copied}
type="html"
text={htmlCode}
icon={Code2}
label="HTML"
onCopy={() => handleCopy(htmlCode, "html")}
/>
<CopyButton
<BadgeCopyButton
copied={copied}
type="link"
text={badgeUrl}
icon={Link2}
label={t("profile.badge.image-link")}
onCopy={() => handleCopy(badgeUrl, "link")}
/>
<Button
asChild
Expand Down
3 changes: 1 addition & 2 deletions src/features/user/components/score-info-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/dialog"
import {
Expand Down Expand Up @@ -146,4 +145,4 @@ export function ScoreInfoModal({ open, onOpenChange }: ScoreInfoModalProps) {
</DialogContent>
</Dialog>
)
}
}
Loading
Loading