Skip to content

Commit fdeee69

Browse files
committed
feat: dock widget
1 parent 3906e28 commit fdeee69

6 files changed

Lines changed: 201 additions & 32 deletions

File tree

public/locales/en/translation.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"moreProjectsOn": "More projects on"
2828
},
2929
"about": {
30-
"title": "More about me"
30+
"title": "More about me",
31+
"appsInDock": "Cool apps in my dock"
3132
},
3233
"contact": {
3334
"currently": "it is",

public/locales/fr/translation.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"moreProjectsOn": "Plus de projets sur"
2828
},
2929
"about": {
30-
"title": "Plus à propos de moi"
30+
"title": "Plus à propos de moi",
31+
"appsInDock": "Apps sympas dans mon dock"
3132
},
3233
"contact": {
3334
"currently": "il est",

src/components/About.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,42 @@
11
import { useTranslation } from "react-i18next";
22
import Technologies from "./Technologies";
33
import { useQuery } from "@tanstack/react-query";
4-
import { fetchPortfolioTools, QUERY_KEYS, type PortfolioTool } from "../utils/fetch";
4+
import { fetchPortfolioResponse, PortfolioResponse, QUERY_KEYS } from "../utils/fetch";
55
import { BodyContainer, SectionTitle } from "../styled/shared";
66
import MusicWidget from "./MusicWidget";
77
import LocationWidget from "./LocationWidget";
88
import Socials from "./Socials";
9+
import DockWidget from "./DockWidget";
910

1011
const About = () => {
1112
const { t } = useTranslation();
12-
const { data: recentTools, isPending } = useQuery<PortfolioTool[]>({
13-
queryKey: [QUERY_KEYS.portfolioTools],
14-
queryFn: fetchPortfolioTools,
13+
const { data: portfolioResponse } = useQuery<PortfolioResponse>({
14+
queryKey: [QUERY_KEYS.portfolioResponse],
15+
queryFn: fetchPortfolioResponse,
1516
retry: false,
1617
});
17-
const tools = isPending ? ["..."] : recentTools?.map((tool) => tool.name) ?? [];
18+
19+
const tools = portfolioResponse?.tools?.map((tool) => tool.name) ?? [];
20+
const dockItems = portfolioResponse?.dock ?? [];
1821

1922
return (
2023
<>
2124
<SectionTitle>{t("about.title")}</SectionTitle>
22-
<BodyContainer>
23-
<span>{t("presentation.enjoyedRecently")}</span>
24-
<Technologies items={tools} />
25-
</BodyContainer>
25+
26+
{tools.length > 0 && (
27+
<BodyContainer>
28+
<span>{t("presentation.enjoyedRecently")}</span>
29+
<Technologies items={tools} />
30+
</BodyContainer>
31+
)}
32+
33+
{dockItems.length > 0 && (
34+
<BodyContainer>
35+
<span>{t("about.appsInDock")}</span>
36+
<DockWidget items={dockItems} />
37+
</BodyContainer>
38+
)}
39+
2640
<MusicWidget />
2741
<LocationWidget />
2842
<Socials />

src/components/DockWidget.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { useState, useEffect, useRef, useCallback } from "react";
2+
import styled from "styled-components";
3+
import { WidgetContainer } from "../styled/shared";
4+
import { Tooltip } from "./Tooltip";
5+
import type { DockApp } from "../utils/fetch";
6+
7+
const DockGlass = styled.div<{ $isOverflowing: boolean }>`
8+
display: flex;
9+
justify-content: ${({ $isOverflowing }) => ($isOverflowing ? "flex-start" : "center")};
10+
gap: 0.5rem;
11+
padding: 0.5rem;
12+
border-radius: 1.25rem;
13+
max-width: 100%;
14+
15+
background: linear-gradient(135deg, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.1));
16+
backdrop-filter: blur(20px) saturate(180%);
17+
border: 1px solid rgba(255, 255, 255, 0.18);
18+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.3);
19+
20+
flex-wrap: nowrap;
21+
overflow-x: auto;
22+
overscroll-behavior-x: contain;
23+
-webkit-overflow-scrolling: touch;
24+
scrollbar-width: none;
25+
&::-webkit-scrollbar {
26+
display: none;
27+
}
28+
29+
@media (max-width: 768px) {
30+
gap: 0.4rem;
31+
border-radius: 1rem;
32+
}
33+
`;
34+
35+
const DockInfoItem = styled.div`
36+
width: 56px;
37+
height: 56px;
38+
flex-shrink: 0;
39+
transform-origin: bottom;
40+
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
41+
cursor: pointer;
42+
user-select: none;
43+
44+
&:hover {
45+
transform: translateY(-4px) scale(1.08);
46+
}
47+
48+
&:active {
49+
transform: translateY(-2px) scale(1.04);
50+
transition: transform 0.1s ease;
51+
}
52+
53+
@media (max-width: 768px) {
54+
width: 48px;
55+
height: 48px;
56+
}
57+
`;
58+
59+
const DockImage = styled.img`
60+
width: 100%;
61+
height: 100%;
62+
object-fit: cover;
63+
pointer-events: none;
64+
`;
65+
66+
const DockWidget = ({ items }: { items?: DockApp[] }) => {
67+
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
68+
const [isOverflowing, setIsOverflowing] = useState(false);
69+
const containerRef = useRef<HTMLDivElement>(null);
70+
const dockRef = useRef<HTMLDivElement>(null);
71+
72+
const handleToggleTooltip = useCallback((appName: string) => {
73+
setOpenTooltip((current) => (current === appName ? null : appName));
74+
}, []);
75+
76+
// Check if dock content overflows
77+
useEffect(() => {
78+
const checkOverflow = () => {
79+
if (dockRef.current) {
80+
const isOverflow = dockRef.current.scrollWidth > dockRef.current.clientWidth;
81+
setIsOverflowing(isOverflow);
82+
}
83+
};
84+
85+
checkOverflow();
86+
87+
// Debounce resize events for better performance
88+
let timeoutId: NodeJS.Timeout;
89+
const debouncedCheckOverflow = () => {
90+
clearTimeout(timeoutId);
91+
timeoutId = setTimeout(checkOverflow, 150);
92+
};
93+
94+
window.addEventListener("resize", debouncedCheckOverflow);
95+
return () => {
96+
window.removeEventListener("resize", debouncedCheckOverflow);
97+
clearTimeout(timeoutId);
98+
};
99+
}, [items]);
100+
101+
useEffect(() => {
102+
if (!openTooltip) return;
103+
104+
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
105+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
106+
setOpenTooltip(null);
107+
}
108+
};
109+
110+
document.addEventListener("mousedown", handleClickOutside);
111+
document.addEventListener("touchstart", handleClickOutside, { passive: true });
112+
113+
return () => {
114+
document.removeEventListener("mousedown", handleClickOutside);
115+
document.removeEventListener("touchstart", handleClickOutside);
116+
};
117+
}, [openTooltip]);
118+
119+
return (
120+
<WidgetContainer ref={containerRef}>
121+
<DockGlass ref={dockRef} $isOverflowing={isOverflowing}>
122+
{items?.map((app) => (
123+
<Tooltip
124+
portal
125+
key={app.name}
126+
content={app.name}
127+
side="top"
128+
sideOffset={12}
129+
open={openTooltip === app.name}
130+
onOpenChange={(isOpen) => setOpenTooltip(isOpen ? app.name : null)}>
131+
<DockInfoItem onTouchStart={() => handleToggleTooltip(app.name)}>
132+
<DockImage src={app.image} alt={app.name} loading="lazy" />
133+
</DockInfoItem>
134+
</Tooltip>
135+
))}
136+
</DockGlass>
137+
</WidgetContainer>
138+
);
139+
};
140+
141+
export default DockWidget;

src/components/Tooltip.tsx

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
33
import styled, { css } from "styled-components";
44
import { scaleInSubtle, scaleOutSubtle } from "../styled/animations";
55

6+
type TooltipContentProps = React.ComponentProps<typeof TooltipPrimitive.Content>;
7+
68
type TooltipProps = TooltipPrimitive.TooltipProps &
7-
Pick<React.ComponentProps<typeof TooltipPrimitive.Content>, "side"> & {
9+
Pick<TooltipContentProps, "side" | "sideOffset"> & {
810
content: React.ReactNode;
911
link?: boolean;
1012
forceAnimation?: boolean;
13+
portal?: boolean;
1114
};
1215

1316
const TOOLTIP_BG_COLOR = "#333";
@@ -47,10 +50,28 @@ export function Tooltip({
4750
defaultOpen,
4851
onOpenChange,
4952
side = "bottom",
53+
sideOffset = 3,
5054
link = false,
5155
forceAnimation = false,
56+
portal = false,
5257
...props
5358
}: TooltipProps) {
59+
const tooltipBody = (
60+
<TooltipContentStyled
61+
side={side}
62+
align="center"
63+
collisionPadding={5}
64+
sideOffset={sideOffset}
65+
$forceAnimation={forceAnimation}
66+
onPointerDownOutside={(event) => {
67+
event.preventDefault();
68+
}}
69+
{...props}>
70+
{content}
71+
<TooltipArrowStyled width={12} height={6} />
72+
</TooltipContentStyled>
73+
);
74+
5475
return (
5576
<TooltipPrimitive.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} delayDuration={0}>
5677
<TooltipPrimitive.Trigger
@@ -67,19 +88,7 @@ export function Tooltip({
6788
}}>
6889
<div>{children}</div>
6990
</TooltipPrimitive.Trigger>
70-
<TooltipContentStyled
71-
side={side}
72-
align="center"
73-
collisionPadding={5}
74-
sideOffset={3}
75-
$forceAnimation={forceAnimation}
76-
onPointerDownOutside={(event) => {
77-
event.preventDefault();
78-
}}
79-
{...props}>
80-
{content}
81-
<TooltipArrowStyled width={12} height={6} />
82-
</TooltipContentStyled>
91+
{portal ? <TooltipPrimitive.Portal>{tooltipBody}</TooltipPrimitive.Portal> : tooltipBody}
8392
</TooltipPrimitive.Root>
8493
);
8594
}

src/utils/fetch.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const QUERY_KEYS = {
2-
portfolioTools: "portfolio-tools",
2+
portfolioResponse: "portfolio-response",
33
randomTrack: "random-track",
44
projectStats: "project-stats",
55
githubUser: "github-user",
@@ -49,23 +49,26 @@ export async function fetchProjectStats(endpoint: string): Promise<Record<string
4949
return (await response.json()) as Record<string, unknown>;
5050
}
5151

52-
export interface PortfolioTool {
52+
interface PortfolioTool {
5353
name: string;
5454
link: string;
5555
}
5656

57-
interface PortfolioResponse {
57+
export interface DockApp {
58+
name: string;
59+
image: string;
60+
}
61+
export interface PortfolioResponse {
5862
tools?: PortfolioTool[];
63+
dock?: DockApp[];
5964
}
6065

61-
export async function fetchPortfolioTools(): Promise<PortfolioTool[]> {
66+
export async function fetchPortfolioResponse(): Promise<PortfolioResponse> {
6267
const response = await fetch("https://static.axlz.me/api/portfolio");
6368

6469
if (!response.ok) {
6570
throw new Error("Unable to fetch portfolio data");
6671
}
6772

68-
const data = (await response.json()) as PortfolioResponse;
69-
70-
return data.tools ?? [];
73+
return (await response.json()) as PortfolioResponse;
7174
}

0 commit comments

Comments
 (0)