feat: add initial implementation for fetching avatar data from API

- Created a new sync.ts file to handle API requests.
- Defined interfaces for AvatarApiResponse, Data, and Avatar.
- Implemented a main function to fetch avatar data from the specified endpoint.
This commit is contained in:
2025-09-23 09:27:51 +07:00 Unverified
parent be6b01a182
commit bdef3c3bd0
21 changed files with 694 additions and 171 deletions
+97
View File
@@ -0,0 +1,97 @@
import { AutoTextSize } from "auto-text-size";
import Image from "next/image";
import type { characters } from "@/lib/db/schema";
// TODO: Change amber type
export default function Avatar({
char,
scale = 1,
}: {
char: typeof characters.$inferInsert;
scale?: number;
}) {
const star = (key: number) => (
<svg
key={key}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 576 512"
style={{
fill: "rgba(255,204,50)",
filter: "drop-shadow(#e3721b 0px 0px 1px)",
height: 20,
width: 20,
}}
>
<title>Star</title>
<path d="M381.2 150.3l143.7 21.2c11.9 1.7 21.9 10.1 25.7 21.6 3.8 11.6.7 24.2-7.9 32.8L438.5 328.1l24.6 146.6c2 12-2.9 24.2-12.9 31.3-9.9 7.1-23 8-33.7 2.3l-128.4-68.5-128.3 68.5c-10.8 5.7-23.9 4.8-33.8-2.3-9.9-7.1-14.9-19.3-12.8-31.3l24.6-146.6L33.58 225.9c-8.61-8.6-11.67-21.2-7.89-32.8 3.77-11.5 13.74-19.9 25.73-21.6L195 150.3l64.4-132.33C264.7 6.954 275.9-.04 288.1-.04c12.3 0 23.5 6.994 28.8 18.01l64.3 132.33z"></path>
</svg>
);
const stars = [];
for (let i = 0; i < (char.stars || 0); i++) stars.push(star(i));
return (
<div
style={{
width: 128 * scale,
height: 168 * scale,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
width: "128px",
background: "rgba(233, 229, 220)",
borderRadius: ".5rem",
position: "relative",
overflow: "hidden",
transform: `scale(${scale})`,
transformOrigin: "0 0",
}}
>
<Image
src={`/cdn/${char.image}`}
style={{
background: `rgba(${
char.stars === 5
? "200,124,36"
: char.stars === 4
? "148,112,187"
: "100,100,100"
}) linear-gradient(136deg,rgba(49,43,71,.5294117647058824),transparent)`,
borderBottomRightRadius: "1.5rem",
filter: char.stars ? "" : "grayscale(100%) opacity(10%) blur(2px)",
}}
alt={char.name}
height={128}
width={128}
/>
<div
style={{
padding: "8px",
display: "flex",
justifyContent: "center",
alignContent: "center",
fontWeight: "bold",
color: "rgba(74,83,102)",
height: 40,
}}
>
<AutoTextSize mode="oneline" maxFontSizePx={16}>
{char.name}
</AutoTextSize>
</div>
<div
style={{
position: "absolute",
width: "100%",
top: "112px",
display: "flex",
justifyContent: "center",
}}
>
{stars}
</div>
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
export function Blocker({ children }: { children: ReactNode }) {
return (
<div className="blocker backdrop-blur-xl bg-[#222A] flex justify-center items-center absolute top-[-5px] left-[-5px] bottom-[-5px] right-[-5px] rounded-sm z-100 border-t-gray-700 border-l-gray-600 border-1">
{children}
</div>
);
}
+75 -90
View File
@@ -1,28 +1,28 @@
"use client"
"use client";
import React, { useEffect, useRef, useState } from "react"
import {
expandAllFeature,
hotkeysCoreFeature,
searchFeature,
selectionFeature,
syncDataLoaderFeature,
TreeState,
} from "@headless-tree/core"
import { useTree } from "@headless-tree/react"
type TreeState,
} from "@headless-tree/core";
import { useTree } from "@headless-tree/react";
import {
CircleXIcon,
FilterIcon,
FolderIcon,
FolderOpenIcon,
} from "lucide-react"
import { Input } from "@/components/ui/input"
import { Tree, TreeItem, TreeItemLabel } from "@/components/tree"
} from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { Tree, TreeItem, TreeItemLabel } from "@/components/tree";
import { Input } from "@/components/ui/input";
interface Item {
name: string
children?: string[]
name: string;
children?: string[];
}
const items: Record<string, Item> = {
@@ -53,16 +53,16 @@ const items: Record<string, Item> = {
operations: { name: "Operations", children: ["hr", "finance"] },
hr: { name: "HR" },
finance: { name: "Finance" },
}
};
const indent = 20
const indent = 20;
export default function Component() {
// Store the initial expanded items to reset when search is cleared
const initialExpandedItems = ["engineering", "frontend", "design-system"]
const [state, setState] = useState<Partial<TreeState<Item>>>({})
const [searchValue, setSearchValue] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
const initialExpandedItems = ["engineering", "frontend", "design-system"];
const [state, setState] = useState<Partial<TreeState<Item>>>({});
const [searchValue, setSearchValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const tree = useTree<Item>({
state,
@@ -85,115 +85,115 @@ export default function Component() {
searchFeature,
expandAllFeature,
],
})
});
// Handle clearing the search
const handleClearSearch = () => {
setSearchValue("")
setSearchValue("");
// Manually trigger the tree's search onChange with an empty value
// to ensure item.isMatchingSearch() is correctly updated.
const searchProps = tree.getSearchInputElementProps()
const searchProps = tree.getSearchInputElementProps();
if (searchProps.onChange) {
const syntheticEvent = {
target: { value: "" },
} as React.ChangeEvent<HTMLInputElement> // Cast to the expected event type
searchProps.onChange(syntheticEvent)
} as React.ChangeEvent<HTMLInputElement>; // Cast to the expected event type
searchProps.onChange(syntheticEvent);
}
// Reset tree state to initial expanded items
setState((prevState) => ({
...prevState,
expandedItems: initialExpandedItems,
}))
}));
// Clear custom filtered items
setFilteredItems([])
setFilteredItems([]);
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.focus();
// Also clear the internal search input
inputRef.current.value = ""
inputRef.current.value = "";
}
}
};
// Keep track of filtered items separately from the tree's internal search state
const [filteredItems, setFilteredItems] = useState<string[]>([])
const [filteredItems, setFilteredItems] = useState<string[]>([]);
// This function determines if an item should be visible based on our custom filtering
const shouldShowItem = (itemId: string) => {
if (!searchValue || searchValue.length === 0) return true
return filteredItems.includes(itemId)
}
if (!searchValue || searchValue.length === 0) return true;
return filteredItems.includes(itemId);
};
// Update filtered items when search value changes
useEffect(() => {
if (!searchValue || searchValue.length === 0) {
setFilteredItems([])
return
setFilteredItems([]);
return;
}
// Get all items
const allItems = tree.getItems()
const allItems = tree.getItems();
// First, find direct matches
const directMatches = allItems
.filter((item) => {
const name = item.getItemName().toLowerCase()
return name.includes(searchValue.toLowerCase())
const name = item.getItemName().toLowerCase();
return name.includes(searchValue.toLowerCase());
})
.map((item) => item.getId())
.map((item) => item.getId());
// Then, find all parent IDs of matching items
const parentIds = new Set<string>()
const parentIds = new Set<string>();
directMatches.forEach((matchId) => {
let item = tree.getItems().find((i) => i.getId() === matchId)
while (item?.getParent && item.getParent()) {
const parent = item.getParent()
let item = tree.getItems().find((i) => i.getId() === matchId);
while (item?.getParent?.()) {
const parent = item.getParent();
if (parent) {
parentIds.add(parent.getId())
item = parent
parentIds.add(parent.getId());
item = parent;
} else {
break
break;
}
}
})
});
// Find all children of matching items
const childrenIds = new Set<string>()
const childrenIds = new Set<string>();
directMatches.forEach((matchId) => {
const item = tree.getItems().find((i) => i.getId() === matchId)
if (item && item.isFolder()) {
const item = tree.getItems().find((i) => i.getId() === matchId);
if (item?.isFolder()) {
// Get all descendants recursively
const getDescendants = (itemId: string) => {
const children = items[itemId]?.children || []
const children = items[itemId]?.children || [];
children.forEach((childId) => {
childrenIds.add(childId)
childrenIds.add(childId);
if (items[childId]?.children?.length) {
getDescendants(childId)
getDescendants(childId);
}
})
}
});
};
getDescendants(item.getId())
getDescendants(item.getId());
}
})
});
// Combine direct matches, parents, and children
setFilteredItems([
...directMatches,
...Array.from(parentIds),
...Array.from(childrenIds),
])
]);
// Keep all folders expanded during search to ensure all matches are visible
// Store current expanded state first
const currentExpandedItems = tree.getState().expandedItems || []
const currentExpandedItems = tree.getState().expandedItems || [];
// Get all folder IDs that need to be expanded to show matches
const folderIdsToExpand = allItems
.filter((item) => item.isFolder())
.map((item) => item.getId())
.map((item) => item.getId());
// Update expanded items in the tree state
setState((prevState) => ({
@@ -201,8 +201,8 @@ export default function Component() {
expandedItems: [
...new Set([...currentExpandedItems, ...folderIdsToExpand]),
],
}))
}, [searchValue, tree])
}));
}, [searchValue, tree]);
return (
<div className="flex h-full flex-col gap-2 *:nth-2:grow">
@@ -212,40 +212,40 @@ export default function Component() {
className="peer ps-9"
value={searchValue}
onChange={(e) => {
const value = e.target.value
setSearchValue(value)
const value = e.target.value;
setSearchValue(value);
// Apply the search to the tree's internal state as well
const searchProps = tree.getSearchInputElementProps()
const searchProps = tree.getSearchInputElementProps();
if (searchProps.onChange) {
searchProps.onChange(e)
searchProps.onChange(e);
}
if (value.length > 0) {
// If input has at least one character, expand all items
tree.expandAll()
tree.expandAll();
} else {
// If input is cleared, reset to initial expanded state
setState((prevState) => ({
...prevState,
expandedItems: initialExpandedItems,
}))
setFilteredItems([])
}));
setFilteredItems([]);
}
}}
// Prevent the internal search from being cleared on blur
onBlur={(e) => {
// Prevent default blur behavior
e.preventDefault()
e.preventDefault();
// Re-apply the search to ensure it stays active
if (searchValue && searchValue.length > 0) {
const searchProps = tree.getSearchInputElementProps()
const searchProps = tree.getSearchInputElementProps();
if (searchProps.onChange) {
const syntheticEvent = {
target: { value: searchValue },
} as React.ChangeEvent<HTMLInputElement>
searchProps.onChange(syntheticEvent)
} as React.ChangeEvent<HTMLInputElement>;
searchProps.onChange(syntheticEvent);
}
}
}}
@@ -260,6 +260,7 @@ export default function Component() {
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md transition-[color,box-shadow] outline-none focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Clear search"
onClick={handleClearSearch}
type="button"
>
<CircleXIcon className="size-4" aria-hidden="true" />
</button>
@@ -273,7 +274,7 @@ export default function Component() {
</p>
) : (
tree.getItems().map((item) => {
const isVisible = shouldShowItem(item.getId())
const isVisible = shouldShowItem(item.getId());
return (
<TreeItem
@@ -294,26 +295,10 @@ export default function Component() {
</span>
</TreeItemLabel>
</TreeItem>
)
);
})
)}
</Tree>
<p
aria-live="polite"
role="region"
className="text-muted-foreground mt-2 text-xs"
>
Tree with filtering ∙{" "}
<a
href="https://headless-tree.lukasbach.com"
className="hover:text-foreground underline"
target="_blank"
rel="noopener noreferrer"
>
API
</a>
</p>
</div>
)
);
}
+44 -44
View File
@@ -1,48 +1,48 @@
"use client"
"use client";
import * as React from "react"
import { ItemInstance } from "@headless-tree/core"
import { ChevronDownIcon } from "lucide-react"
import { Slot } from "radix-ui"
import type { ItemInstance } from "@headless-tree/core";
import { ChevronDownIcon } from "lucide-react";
import { Slot } from "radix-ui";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface TreeContextValue<T = any> {
indent: number
currentItem?: ItemInstance<T>
tree?: any
indent: number;
currentItem?: ItemInstance<T>;
tree?: any;
}
const TreeContext = React.createContext<TreeContextValue>({
indent: 20,
currentItem: undefined,
tree: undefined,
})
});
function useTreeContext<T = any>() {
return React.useContext(TreeContext) as TreeContextValue<T>
return React.useContext(TreeContext) as TreeContextValue<T>;
}
interface TreeProps extends React.HTMLAttributes<HTMLDivElement> {
indent?: number
tree?: any
indent?: number;
tree?: any;
}
function Tree({ indent = 20, tree, className, ...props }: TreeProps) {
const containerProps =
tree && typeof tree.getContainerProps === "function"
? tree.getContainerProps()
: {}
const mergedProps = { ...props, ...containerProps }
: {};
const mergedProps = { ...props, ...containerProps };
// Extract style from mergedProps to merge with our custom styles
const { style: propStyle, ...otherProps } = mergedProps
const { style: propStyle, ...otherProps } = mergedProps;
// Merge styles
const mergedStyle = {
...propStyle,
"--tree-indent": `${indent}px`,
} as React.CSSProperties
} as React.CSSProperties;
return (
<TreeContext.Provider value={{ indent, tree }}>
@@ -53,14 +53,14 @@ function Tree({ indent = 20, tree, className, ...props }: TreeProps) {
{...otherProps}
/>
</TreeContext.Provider>
)
);
}
interface TreeItemProps<T = any>
extends React.HTMLAttributes<HTMLButtonElement> {
item: ItemInstance<T>
indent?: number
asChild?: boolean
item: ItemInstance<T>;
indent?: number;
asChild?: boolean;
}
function TreeItem<T = any>({
@@ -70,21 +70,21 @@ function TreeItem<T = any>({
children,
...props
}: Omit<TreeItemProps<T>, "indent">) {
const { indent } = useTreeContext<T>()
const { indent } = useTreeContext<T>();
const itemProps = typeof item.getProps === "function" ? item.getProps() : {}
const mergedProps = { ...props, ...itemProps }
const itemProps = typeof item.getProps === "function" ? item.getProps() : {};
const mergedProps = { ...props, ...itemProps };
// Extract style from mergedProps to merge with our custom styles
const { style: propStyle, ...otherProps } = mergedProps
const { style: propStyle, ...otherProps } = mergedProps;
// Merge styles
const mergedStyle = {
...propStyle,
"--tree-padding": `${item.getItemMeta().level * indent}px`,
} as React.CSSProperties
} as React.CSSProperties;
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : "button";
return (
<TreeContext.Provider value={{ indent, currentItem: item }}>
@@ -93,7 +93,7 @@ function TreeItem<T = any>({
style={mergedStyle}
className={cn(
"z-10 ps-(--tree-padding) outline-hidden select-none not-last:pb-0.5 focus:z-20 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
className,
)}
data-focus={
typeof item.isFocused === "function"
@@ -126,12 +126,12 @@ function TreeItem<T = any>({
{children}
</Comp>
</TreeContext.Provider>
)
);
}
interface TreeItemLabelProps<T = any>
extends React.HTMLAttributes<HTMLSpanElement> {
item?: ItemInstance<T>
item?: ItemInstance<T>;
}
function TreeItemLabel<T = any>({
@@ -140,12 +140,12 @@ function TreeItemLabel<T = any>({
className,
...props
}: TreeItemLabelProps<T>) {
const { currentItem } = useTreeContext<T>()
const item = propItem || currentItem
const { currentItem } = useTreeContext<T>();
const item = propItem || currentItem;
if (!item) {
console.warn("TreeItemLabel: No item provided via props or context")
return null
console.warn("TreeItemLabel: No item provided via props or context");
return null;
}
return (
@@ -153,7 +153,7 @@ function TreeItemLabel<T = any>({
data-slot="tree-item-label"
className={cn(
"in-focus-visible:ring-ring/50 bg-background hover:bg-accent in-data-[selected=true]:bg-accent in-data-[selected=true]:text-accent-foreground in-data-[drag-target=true]:bg-accent flex items-center gap-1 rounded-sm px-2 py-1.5 text-sm transition-colors not-in-data-[folder=true]:ps-7 in-focus-visible:ring-[3px] in-data-[search-match=true]:bg-blue-400/20! [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
className,
)}
{...props}
>
@@ -163,33 +163,33 @@ function TreeItemLabel<T = any>({
{children ||
(typeof item.getItemName === "function" ? item.getItemName() : null)}
</span>
)
);
}
function TreeDragLine({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const { tree } = useTreeContext()
const { tree } = useTreeContext();
if (!tree || typeof tree.getDragLineStyle !== "function") {
console.warn(
"TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method"
)
return null
"TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method",
);
return null;
}
const dragLine = tree.getDragLineStyle()
const dragLine = tree.getDragLineStyle();
return (
<div
style={dragLine}
className={cn(
"bg-primary before:bg-background before:border-primary absolute z-30 -mt-px h-0.5 w-[unset] before:absolute before:-top-[3px] before:left-0 before:size-2 before:rounded-full before:border-2",
className
className,
)}
{...props}
/>
)
);
}
export { Tree, TreeItem, TreeItemLabel, TreeDragLine }
export { Tree, TreeItem, TreeItemLabel, TreeDragLine };
+9 -9
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function ScrollArea({
className,
@@ -25,7 +25,7 @@ function ScrollArea({
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
);
}
function ScrollBar({
@@ -43,16 +43,16 @@ function ScrollBar({
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
className="bg-white opacity-50 relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
);
}
export { ScrollArea, ScrollBar }
export { ScrollArea, ScrollBar };