(`${apiUrl}getGames?key=${config.steamApiKey}&steamId=${id}&withDetails=true`, null, (o) => {
+ saveGames(o, id)
+ }) as SteamPlayerGames | undefined
+}
+
export const getPlayerInfoStream = async (id: number[], cancellation: AbortSignal, callback: (data: SteamPlayerSummary) => void) => {
const response = await handleResponse(`${apiUrl}getSummariesStream?key=${config.steamApiKey}`, {
method: "POST",
diff --git a/SteamOrganizer.Web/src/shared/hoc/withControlledState.tsx b/SteamOrganizer.Web/src/shared/hoc/withControlledState.tsx
new file mode 100644
index 00000000..a1731890
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/hoc/withControlledState.tsx
@@ -0,0 +1,42 @@
+import {ComponentType, Dispatch, forwardRef, SetStateAction} from "react";
+import {IControlledStateOptions, useControlledState} from "@/shared/hooks/useControlledState";
+
+/**
+ * HOC to wrap a component with controlled state
+ * @param WrappedComponent Component to wrap
+ * @param defaultValue Default value for the controlled state
+ */
+export function withControlledState(
+ WrappedComponent: ComponentType
> }>,
+ defaultValue?: T
+) {
+ return forwardRef>((props, ref) => {
+ const {
+ state,
+ initialState = defaultValue,
+ bindTo,
+ bindKey,
+ setState,
+ onStateChanged,
+ ...componentProps
+ } = props;
+
+ const { value, setValue } = useControlledState({
+ state,
+ initialState,
+ setState,
+ bindTo,
+ bindKey,
+ onStateChanged
+ });
+
+ return (
+
+ );
+ });
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/hooks/useControlledState.ts b/SteamOrganizer.Web/src/shared/hooks/useControlledState.ts
new file mode 100644
index 00000000..b91ec51e
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/hooks/useControlledState.ts
@@ -0,0 +1,138 @@
+import {
+ type ComponentProps,
+ type Dispatch,
+ type JSXElementConstructor,
+ ReactHTML, SetStateAction,
+ useCallback,
+ useState
+} from "react";
+import type {HTMLMotionProps} from "framer-motion";
+
+type JSXComponent = keyof JSX.IntrinsicElements | JSXElementConstructor;
+
+/**
+ * Interface for controlled state options
+ * @template T The type of the state value
+ */
+export interface IControlledStateOptions {
+ /** Current state value for controlled mode */
+ state?: T;
+
+ /** Initial state value for uncontrolled mode */
+ initialState?: T;
+
+ /** State setter function for controlled mode */
+ setState?: Dispatch>;
+
+ /** Callback fired when state changes in uncontrolled mode */
+ onStateChanged?: (value: T) => void;
+
+ bindTo?: object;
+ bindKey?: string;
+}
+
+/**
+ * Interface for the controlled state hook result
+ * @template T The type of the state value
+ */
+interface IControlledStateResult {
+ /** Current state value */
+ value: T;
+
+ /** Function to update the state */
+ setValue: Dispatch>;
+
+ /** Whether the component is in controlled mode */
+ isControlled: boolean;
+}
+
+/**
+ * Type for creating stateful component props
+ * Combines base props, controlled state options, and native element props
+ *
+ * @template T Component HTML type
+ * @template S State value type
+ */
+export type StatefulComponent<
+ T extends JSXComponent,
+ V
+> = IControlledStateOptions & ComponentProps;
+
+/*
+* @template T Component HTML type
+* @template S State value type
+*/
+export type StatefulMotionComponent<
+ T extends keyof ReactHTML,
+ V
+> = IControlledStateOptions & HTMLMotionProps;
+
+
+/**
+ * A hook that provides both controlled and uncontrolled state management
+ *
+ * This hook allows components to work in both controlled and uncontrolled modes,
+ * similar to how native HTML form components work.
+ *
+ * @template T The type of the state value
+ *
+ * @param options Configuration options for the state management
+ * @returns An object containing the current value, setter function, and control mode
+ *
+ * @example
+ * ```tsx
+ * // Uncontrolled usage
+ * function UncontrolledExample() {
+ * const { value, setValue } = useControlledState({
+ * initialState: false,
+ * onStateChanged: (newValue) => console.log('State changed:', newValue)
+ * });
+ *
+ * return ;
+ * }
+ *
+ * // Controlled usage
+ * function ControlledExample() {
+ * const [state, setState] = useState(false);
+ *
+ * const { } = useControlledState({
+ * state,
+ * setState,
+ * });
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function useControlledState({
+ state,
+ initialState,
+ setState,
+ bindTo,
+ bindKey,
+ onStateChanged
+ }: IControlledStateOptions): IControlledStateResult {
+ const [internalValue, setInternalValue] = useState(bindTo && bindKey ? bindTo[bindKey] : initialState);
+ const isControlled = state !== undefined;
+ const currentValue = isControlled ? state : internalValue;
+
+ const setter = useCallback((newValue: SetStateAction) => {
+ newValue = newValue instanceof Function ? newValue(internalValue) : newValue;
+ bindTo && bindKey && (bindTo[bindKey] = newValue)
+
+ if (isControlled) {
+ setState?.(newValue);
+ return;
+ }
+ if (newValue !== internalValue) {
+ setInternalValue(newValue);
+ onStateChanged?.(newValue);
+ }
+ }, [isControlled, setState, onStateChanged, internalValue, bindTo, bindKey]);
+
+ return {
+ value: currentValue,
+ setValue: setter,
+ isControlled
+ };
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/hooks/useFormValidation.ts b/SteamOrganizer.Web/src/shared/hooks/useFormValidation.ts
similarity index 100%
rename from SteamOrganizer.Web/src/hooks/useFormValidation.ts
rename to SteamOrganizer.Web/src/shared/hooks/useFormValidation.ts
diff --git a/SteamOrganizer.Web/src/hooks/useMediaQuery.ts b/SteamOrganizer.Web/src/shared/hooks/useMediaQuery.ts
similarity index 100%
rename from SteamOrganizer.Web/src/hooks/useMediaQuery.ts
rename to SteamOrganizer.Web/src/shared/hooks/useMediaQuery.ts
diff --git a/SteamOrganizer.Web/src/hooks/useLoader.ts b/SteamOrganizer.Web/src/shared/hooks/useObservableLoader.ts
similarity index 72%
rename from SteamOrganizer.Web/src/hooks/useLoader.ts
rename to SteamOrganizer.Web/src/shared/hooks/useObservableLoader.ts
index 49ee95a0..53c5a643 100644
--- a/SteamOrganizer.Web/src/hooks/useLoader.ts
+++ b/SteamOrganizer.Web/src/shared/hooks/useObservableLoader.ts
@@ -1,7 +1,7 @@
import {useEffect, useState} from "react";
-import {ObservableObject} from "@/lib/observer/observableObject.ts";
+import {ObservableObject} from "@/shared/lib/observer/observableObject";
-export const useLoader = (object: ObservableObject) => {
+export const useObservableLoader = (object: ObservableObject) => {
const [isLoading, setLoading] = useState(object.value === undefined)
useEffect(() => {
diff --git a/SteamOrganizer.Web/src/hooks/useScrollbar.ts b/SteamOrganizer.Web/src/shared/hooks/useScrollbar.ts
similarity index 77%
rename from SteamOrganizer.Web/src/hooks/useScrollbar.ts
rename to SteamOrganizer.Web/src/shared/hooks/useScrollbar.ts
index 4d7aa700..394c6be6 100644
--- a/SteamOrganizer.Web/src/hooks/useScrollbar.ts
+++ b/SteamOrganizer.Web/src/shared/hooks/useScrollbar.ts
@@ -13,6 +13,10 @@ const options: PartialOptions = {
}
}
+export const getOverlayScrollbar = (host: HTMLDivElement, events?: EventListeners | undefined) => {
+ return OverlayScrollbars(host, options, events)
+}
+
export const useScrollbar = (events?: EventListeners | undefined, deps: DependencyList = []) => {
const hostRef = useRef(null)
const scrollRef = useRef()
@@ -20,7 +24,7 @@ export const useScrollbar = (events?: EventListeners | undefined, deps: Dependen
if(!hostRef.current)
return
- const scrollbars = OverlayScrollbars(hostRef.current!,options,events)
+ const scrollbars = getOverlayScrollbar(hostRef.current, events)
scrollRef.current = scrollbars.elements().viewport
return () => scrollbars.destroy()
}, deps)
diff --git a/SteamOrganizer.Web/src/hooks/useSlider.ts b/SteamOrganizer.Web/src/shared/hooks/useSlider.ts
similarity index 100%
rename from SteamOrganizer.Web/src/hooks/useSlider.ts
rename to SteamOrganizer.Web/src/shared/hooks/useSlider.ts
diff --git a/SteamOrganizer.Web/src/shared/hooks/useTitle.ts b/SteamOrganizer.Web/src/shared/hooks/useTitle.ts
new file mode 100644
index 00000000..ee2b1dc1
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/hooks/useTitle.ts
@@ -0,0 +1,6 @@
+import { type DependencyList, useEffect} from "react";
+import {setDocumentTitle} from "@/shared/lib/utils";
+
+export const useTitle = (title: string, deps?: DependencyList) => {
+ useEffect(() => setDocumentTitle(title), deps);
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/lib/md5.js b/SteamOrganizer.Web/src/shared/lib/md5.js
similarity index 100%
rename from SteamOrganizer.Web/src/lib/md5.js
rename to SteamOrganizer.Web/src/shared/lib/md5.js
diff --git a/SteamOrganizer.Web/src/lib/observer/eventEmitter.ts b/SteamOrganizer.Web/src/shared/lib/observer/eventEmitter.ts
similarity index 100%
rename from SteamOrganizer.Web/src/lib/observer/eventEmitter.ts
rename to SteamOrganizer.Web/src/shared/lib/observer/eventEmitter.ts
diff --git a/SteamOrganizer.Web/src/lib/observer/observableObject.ts b/SteamOrganizer.Web/src/shared/lib/observer/observableObject.ts
similarity index 79%
rename from SteamOrganizer.Web/src/lib/observer/observableObject.ts
rename to SteamOrganizer.Web/src/shared/lib/observer/observableObject.ts
index b6573388..2ff49e9a 100644
--- a/SteamOrganizer.Web/src/lib/observer/observableObject.ts
+++ b/SteamOrganizer.Web/src/shared/lib/observer/observableObject.ts
@@ -1,5 +1,6 @@
-import {Observer} from "./observer.ts";
+import {Observer} from "./observer";
+export type ObservableCollection = ObservableObject
export class ObservableObject extends Observer {
public constructor(object: T) {
diff --git a/SteamOrganizer.Web/src/lib/observer/observableProxy.ts b/SteamOrganizer.Web/src/shared/lib/observer/observableProxy.ts
similarity index 67%
rename from SteamOrganizer.Web/src/lib/observer/observableProxy.ts
rename to SteamOrganizer.Web/src/shared/lib/observer/observableProxy.ts
index 11bf9fa7..55d04c5e 100644
--- a/SteamOrganizer.Web/src/lib/observer/observableProxy.ts
+++ b/SteamOrganizer.Web/src/shared/lib/observer/observableProxy.ts
@@ -1,23 +1,28 @@
-import {Observer} from "./observer.ts";
+import {Observer} from "./observer";
+import {ObservableObject} from "@/shared/lib/observer/observableObject";
export class ObservableProxy extends Observer {
private middleware: Array<(data: T) => T> = [];
- private observer: Observer;
+ public readonly observer: ObservableObject;
- constructor(observer: Observer) {
+ constructor(observer: ObservableObject) {
super();
this.observer = observer
this.value = observer.value
- observer.onChanged(this.proxyCallback)
+ observer.onChanged((newData) => {
+ this.value = newData;
+ this.proxyCallback(newData)
+ })
}
public proxyCallback = (data: T) => {
- this.value = data
for(const middleware of this.middleware) {
data = middleware(data)
}
- return this.subscribers.emit(data)
+
+ this.value = data
+ this.subscribers.emit(data)
}
public addMiddleware(middleware: (data: T) => T) {
diff --git a/SteamOrganizer.Web/src/lib/observer/observer.ts b/SteamOrganizer.Web/src/shared/lib/observer/observer.ts
similarity index 88%
rename from SteamOrganizer.Web/src/lib/observer/observer.ts
rename to SteamOrganizer.Web/src/shared/lib/observer/observer.ts
index 33236f2e..402af499 100644
--- a/SteamOrganizer.Web/src/lib/observer/observer.ts
+++ b/SteamOrganizer.Web/src/shared/lib/observer/observer.ts
@@ -1,4 +1,4 @@
-import {EventEmitter} from "./eventEmitter.ts";
+import {EventEmitter} from "./eventEmitter";
export abstract class Observer {
// Original value
diff --git a/SteamOrganizer.Web/src/lib/rxStore.tsx b/SteamOrganizer.Web/src/shared/lib/rxStore.tsx
similarity index 90%
rename from SteamOrganizer.Web/src/lib/rxStore.tsx
rename to SteamOrganizer.Web/src/shared/lib/rxStore.tsx
index eb408489..8d1d431f 100644
--- a/SteamOrganizer.Web/src/lib/rxStore.tsx
+++ b/SteamOrganizer.Web/src/shared/lib/rxStore.tsx
@@ -1,5 +1,5 @@
import {useState, useEffect, type Dispatch, type SetStateAction} from 'react';
-import {debounce} from "@/lib/utils.ts";
+import {debounce} from "@/shared/lib/utils";
export class RxStore {
public readonly store: T = {} as T;
@@ -53,9 +53,9 @@ export class RxStore {
}
}
-export const useStoreState = (store: RxStore, key: string):
- [state: T, value: (value: SetStateAction, serialize?: boolean) => void] => {
- const [state, setState] = useState(store.store[key]);
+export const useStoreState = (store: RxStore, key: string, defaultValue?: T):
+ [state: T, value: (value: T, serialize?: boolean) => void] => {
+ const [state, setState] = useState(store.store[key] ?? defaultValue);
useEffect(() => {
store.set(key, setState)
diff --git a/SteamOrganizer.Web/src/lib/steamIdConverter.ts b/SteamOrganizer.Web/src/shared/lib/steamIdConverter.ts
similarity index 93%
rename from SteamOrganizer.Web/src/lib/steamIdConverter.ts
rename to SteamOrganizer.Web/src/shared/lib/steamIdConverter.ts
index 1b981dfc..5c5dcf7e 100644
--- a/SteamOrganizer.Web/src/lib/steamIdConverter.ts
+++ b/SteamOrganizer.Web/src/shared/lib/steamIdConverter.ts
@@ -1,6 +1,6 @@
-import { md5 } from "@/lib/md5.js";
-import {fromLittleEndian, toLittleEndian} from "@/lib/utils.ts";
-import {resolveVanityUrl} from "@/services/steamApi.ts";
+import { md5 } from "@/shared/lib/md5.js";
+import {fromLittleEndian, toLittleEndian} from "@/shared/lib/utils";
+import {resolveVanityUrl} from "@/shared/api/steamApi";
export const id64Indent = 76561197960265728n;
const Base32 = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -21,7 +21,7 @@ type ISteamIdConverter = {
from: (accountId: number) => string;
}
-export const converters: ISteamIdConverter[] = [
+export const idConverters: ISteamIdConverter[] = [
{
matcher: new RegExp("^[0-9]{1,10}$"),
to: id => Number(id),
@@ -62,7 +62,7 @@ const idFromUrl = new RegExp("/(?:id|profiles)/([^/, ]+)")
export const toAccountId = async (steamId: string) => {
if(!steamId) return undefined
const id = steamId.match(idFromUrl)?.[1] ?? steamId
- for (const converter of converters) {
+ for (const converter of idConverters) {
if(converter.matcher.test(id)) {
return converter.to(id)
}
diff --git a/SteamOrganizer.Web/src/shared/lib/timeFormatting.ts b/SteamOrganizer.Web/src/shared/lib/timeFormatting.ts
new file mode 100644
index 00000000..41df0404
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/lib/timeFormatting.ts
@@ -0,0 +1,104 @@
+export const enum ETimeUnit {
+ Years,
+ Months,
+ Days,
+ Hours,
+ Minutes,
+ Seconds,
+}
+
+export const enum TimeFormat {
+ None = 0,
+ Seconds = 1 << 0,
+ Minutes = 1 << 1,
+ Hours = 1 << 2,
+ Days = 1 << 3,
+ Months = 1 << 4,
+ Years = 1 << 5,
+ All = Years | Months | Days | Hours | Minutes | Seconds
+}
+
+interface TimeUnitConfig {
+ s: number;
+ format: TimeFormat;
+ name: string;
+}
+
+const units: TimeUnitConfig[] = [
+ {
+ s: 31536000,
+ format: TimeFormat.Years,
+ name: 'year'
+ },
+ {
+ s: 2592000,
+ format: TimeFormat.Months,
+ name: 'month'
+ },
+ {
+ s: 86400,
+ format: TimeFormat.Days,
+ name: 'day'
+ },
+ {
+ s: 3600,
+ format: TimeFormat.Hours,
+ name: 'hour'
+ },
+ {
+ s: 60,
+ format: TimeFormat.Minutes,
+ name: 'minute'
+ },
+ {
+ s: 1,
+ format: TimeFormat.Seconds,
+ name: 'second'
+ }
+];
+
+const dateOptions = {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+} satisfies Intl.DateTimeFormatOptions;
+
+export const dateFormatter = new Intl.DateTimeFormat(navigator.language, dateOptions);
+
+export function formatTimeDifference(
+ value: number,
+ inputUnit: ETimeUnit = ETimeUnit.Seconds,
+ outputFormat: TimeFormat = TimeFormat.All
+): string {
+ let remaining = value * units[inputUnit].s;
+ const parts: string[] = [];
+
+ for (let i = 0; i <= ETimeUnit.Seconds; i++) {
+ const config = units[i];
+ if (!(outputFormat & config.format)) {
+ continue;
+ }
+
+ const value = Math.floor(remaining / config.s);
+ const isLastUnit = units.find(u => outputFormat & u.format) === config;
+
+ if (value > 0 || (parts.length > 0 && isLastUnit)) {
+ parts.push(`${value} ${config.name}${value !== 1 ? 's' : ''}`);
+ remaining %= config.s;
+ }
+ }
+
+ return parts.join(' ');
+}
+
+export const formatFileDate = (date: Date = new Date()) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const seconds = String(date.getSeconds()).padStart(2, '0');
+ return `${year}-${month}-${day} ${hours}-${minutes}-${seconds}`;
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/lib/utils.ts b/SteamOrganizer.Web/src/shared/lib/utils.ts
similarity index 75%
rename from SteamOrganizer.Web/src/lib/utils.ts
rename to SteamOrganizer.Web/src/shared/lib/utils.ts
index b7e21201..757ad606 100644
--- a/SteamOrganizer.Web/src/lib/utils.ts
+++ b/SteamOrganizer.Web/src/shared/lib/utils.ts
@@ -1,26 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
-const dateOptions = {
- day: 'numeric',
- month: 'long',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
-} satisfies Intl.DateTimeFormatOptions;
-
-export const dateFormatter = new Intl.DateTimeFormat(navigator.language, dateOptions);
-
-export const formatFileDate = (date: Date = new Date()) => {
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- const hours = String(date.getHours()).padStart(2, '0');
- const minutes = String(date.getMinutes()).padStart(2, '0');
- const seconds = String(date.getSeconds()).padStart(2, '0');
- return `${year}-${month}-${day} ${hours}-${minutes}-${seconds}`;
-}
-
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -49,6 +29,20 @@ export const debounce = any>(func: T, delay: numb
};
};
+export function getScrollParent(element: HTMLElement) {
+ while (element && element !== document.body) {
+ const style = window.getComputedStyle(element);
+ const overflowY = style.overflowY;
+
+ if (overflowY === 'scroll' || overflowY === 'auto') {
+ return element;
+ }
+ element = element.parentElement;
+ }
+
+ return window;
+}
+
/**
* Utility function to get event coordinates.
*
diff --git a/SteamOrganizer.Web/src/services/cryptography.ts b/SteamOrganizer.Web/src/shared/services/cryptography.ts
similarity index 100%
rename from SteamOrganizer.Web/src/services/cryptography.ts
rename to SteamOrganizer.Web/src/shared/services/cryptography.ts
diff --git a/SteamOrganizer.Web/src/services/gAuth.ts b/SteamOrganizer.Web/src/shared/services/gAuth.ts
similarity index 94%
rename from SteamOrganizer.Web/src/services/gAuth.ts
rename to SteamOrganizer.Web/src/shared/services/gAuth.ts
index 6fa19d01..a2c359fa 100644
--- a/SteamOrganizer.Web/src/services/gAuth.ts
+++ b/SteamOrganizer.Web/src/shared/services/gAuth.ts
@@ -1,4 +1,4 @@
-import {ObservableObject} from "@/lib/observer/observableObject.ts";
+import {ObservableObject} from "@/shared/lib/observer/observableObject";
import { gapi } from 'gapi-script';
export const isAuthorized = new ObservableObject(undefined!)
diff --git a/SteamOrganizer.Web/src/services/gDrive.ts b/SteamOrganizer.Web/src/shared/services/gDrive.ts
similarity index 97%
rename from SteamOrganizer.Web/src/services/gDrive.ts
rename to SteamOrganizer.Web/src/shared/services/gDrive.ts
index fcc80466..38d76dd9 100644
--- a/SteamOrganizer.Web/src/services/gDrive.ts
+++ b/SteamOrganizer.Web/src/shared/services/gDrive.ts
@@ -1,4 +1,4 @@
-import { gapi } from 'gapi-script';
+import {gapi} from 'gapi-script';
export type GDriveFile = {
id: string;
@@ -10,6 +10,7 @@ export type GDriveFile = {
type FileList = {
files?: GDriveFile[]
+ nextPageToken?: string
}
type GDriveResponse = {
@@ -77,7 +78,7 @@ const getFileMetadata = (query: string, fields: string, limit: number = 1, pageT
method: "GET",
params: {
q: query,
- fields: `files(${fields})`,
+ fields: `files(${fields}), nextPageToken`,
pageSize: limit,
pageToken: pageToken,
orderBy: "createdTime desc"
diff --git a/SteamOrganizer.Web/src/services/indexedDb.ts b/SteamOrganizer.Web/src/shared/services/indexedDb.ts
similarity index 100%
rename from SteamOrganizer.Web/src/services/indexedDb.ts
rename to SteamOrganizer.Web/src/shared/services/indexedDb.ts
diff --git a/SteamOrganizer.Web/src/components/primitives/types/IBindable.ts b/SteamOrganizer.Web/src/shared/types/IBindable.ts
similarity index 100%
rename from SteamOrganizer.Web/src/components/primitives/types/IBindable.ts
rename to SteamOrganizer.Web/src/shared/types/IBindable.ts
diff --git a/SteamOrganizer.Web/src/components/primitives/Button.tsx b/SteamOrganizer.Web/src/shared/ui/Button.tsx
similarity index 81%
rename from SteamOrganizer.Web/src/components/primitives/Button.tsx
rename to SteamOrganizer.Web/src/shared/ui/Button.tsx
index f76dd9b6..8f329e83 100644
--- a/SteamOrganizer.Web/src/components/primitives/Button.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/Button.tsx
@@ -8,15 +8,17 @@ import {
useRef,
useState
} from "react";
-import {cn} from "@/lib/utils.ts";
+import {cn} from "@/shared/lib/utils";
export const enum EButtonVariant {
+ None,
Primary,
Outlined,
Transparent,
}
export const enum EButtonSize {
+ None,
Default
}
@@ -34,15 +36,15 @@ interface IButtonProps extends ButtonHTMLAttributes {
size?: EButtonSize
}
-const variants = [
- "font-semibold flex-center bg-secondary text-accent hover:text-foreground-accent rounded-xm min-w-32",
- "border-tertiary border text-secondary font-thin hover:bg-tertiary rounded",
- "hover:bg-accent text-foreground-muted hover:text-foreground w-full text-left"
-]
+const variants = {
+ [EButtonVariant.Primary]: "font-semibold flex-center bg-secondary text-accent hover:text-foreground-accent rounded-xm min-w-32",
+ [EButtonVariant.Outlined]: "border-tertiary border text-secondary font-thin hover:bg-tertiary rounded",
+ [EButtonVariant.Transparent]: "hover:bg-accent text-foreground-muted hover:text-foreground w-full text-left"
+}
-const sizes = [
- "px-3 py-1 text-2xs"
-]
+const sizes = {
+ [EButtonSize.Default]: "px-3 py-1 text-2xs"
+}
const Button = forwardRef((
{
diff --git a/SteamOrganizer.Web/src/shared/ui/CheckBox/CheckBox.tsx b/SteamOrganizer.Web/src/shared/ui/CheckBox/CheckBox.tsx
new file mode 100644
index 00000000..1f02d791
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/CheckBox/CheckBox.tsx
@@ -0,0 +1,40 @@
+import {forwardRef, ReactElement} from "react";
+import {cn} from "@/shared/lib/utils";
+import {withControlledState} from "@/shared/hoc/withControlledState";
+import {StatefulComponent} from "@/shared/hooks/useControlledState";
+
+interface ICheckBoxProps extends StatefulComponent<'button', boolean | null>{
+ allowIndeterminate?: boolean
+ checkedSymbol?: ReactElement
+ unCheckedSymbol?: ReactElement
+ indeterminateSymbol?: ReactElement
+}
+
+const CheckBoxBase = forwardRef(({ className,
+ state,
+ setState,
+ checkedSymbol,
+ unCheckedSymbol,
+ indeterminateSymbol,
+ allowIndeterminate,
+ ...props }, ref) => {
+ const toggleCheck = () => {
+ setState(prev => {
+ return prev === null ? false : prev ? (allowIndeterminate ? null : false) : true;
+ });
+ };
+
+ return (
+
+ )
+})
+
+export const CheckBox = withControlledState(CheckBoxBase, false);
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/components/primitives/ComboBox/ComboBox.module.css b/SteamOrganizer.Web/src/shared/ui/ComboBox/ComboBox.module.css
similarity index 100%
rename from SteamOrganizer.Web/src/components/primitives/ComboBox/ComboBox.module.css
rename to SteamOrganizer.Web/src/shared/ui/ComboBox/ComboBox.module.css
diff --git a/SteamOrganizer.Web/src/components/primitives/ComboBox/ComboBox.tsx b/SteamOrganizer.Web/src/shared/ui/ComboBox/ComboBox.tsx
similarity index 98%
rename from SteamOrganizer.Web/src/components/primitives/ComboBox/ComboBox.tsx
rename to SteamOrganizer.Web/src/shared/ui/ComboBox/ComboBox.tsx
index 80b2b102..72ae9039 100644
--- a/SteamOrganizer.Web/src/components/primitives/ComboBox/ComboBox.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/ComboBox/ComboBox.tsx
@@ -1,5 +1,5 @@
import {CSSProperties, type FC, ReactElement, type ReactNode, useState} from "react";
-import {Icon, SvgIcon} from "src/defines";
+import {Icon, SvgIcon} from "@/defines";
import { motion } from "framer-motion";
import styles from "./ComboBox.module.css";
import {clsx} from "clsx";
diff --git a/SteamOrganizer.Web/src/shared/ui/CopyButton/CopyButton.module.css b/SteamOrganizer.Web/src/shared/ui/CopyButton/CopyButton.module.css
new file mode 100644
index 00000000..da500338
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/CopyButton/CopyButton.module.css
@@ -0,0 +1,12 @@
+.copyButton {
+ @apply bg-secondary p-1 text-foreground-accent rounded-2xm shrink-0;
+}
+
+.copyButton:active {
+ @apply animate-ping;
+}
+
+.copyButton svg {
+ @apply mx-auto;
+ pointer-events: none;
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/CopyButton/CopyButton.tsx b/SteamOrganizer.Web/src/shared/ui/CopyButton/CopyButton.tsx
new file mode 100644
index 00000000..351daa9e
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/CopyButton/CopyButton.tsx
@@ -0,0 +1,50 @@
+import Button, {EButtonSize, EButtonVariant} from "@/shared/ui/Button";
+import {Icon, SvgIcon} from "@/defines";
+import {type ComponentProps, type ElementType, type FC, ReactElement, type ReactNode} from "react";
+import {ContentType, Popup} from "@/shared/ui/Popup/Popup";
+import {EPlacement} from "@/shared/ui/Popup/positioning";
+import styles from "./CopyButton.module.css"
+import {clsx} from "clsx";
+
+type ClipboardDataType = string | number | (() => (string | number));
+
+interface ICopyButtonProps extends ComponentProps<'button'> {
+ copyContent: ClipboardDataType;
+ size?: number
+}
+
+interface ICopyAreaProps extends ComponentProps {
+ copyContent: ClipboardDataType;
+ as: ElementType;
+ children: ReactNode;
+}
+
+const put = (data: ClipboardDataType) =>
+ navigator.clipboard.writeText((data instanceof Function ? data() : data).toString())
+
+const CopyPopup: FC<{children: ReactElement}> = ({ children }) => {
+ return (
+
+ {children}
+
+ )
+}
+
+export const CopyButton: FC = ({ copyContent, className, size = 15, ...props }) => {
+ return
+
+
+}
+
+export const CopyArea: FC = ({as: Component = "div", copyContent, children, ...props}) => {
+ return
+ await put(copyContent)} {...props}>
+ {children}
+
+
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/components/primitives/Draggable.tsx b/SteamOrganizer.Web/src/shared/ui/Draggable.tsx
similarity index 99%
rename from SteamOrganizer.Web/src/components/primitives/Draggable.tsx
rename to SteamOrganizer.Web/src/shared/ui/Draggable.tsx
index bb21a557..373e53a9 100644
--- a/SteamOrganizer.Web/src/components/primitives/Draggable.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/Draggable.tsx
@@ -1,5 +1,5 @@
import React, {FC, ReactElement, useEffect, useRef} from "react";
-import {applyStyles, findParentByAttribute, getEventCords} from "@/lib/utils.ts";
+import {applyStyles, findParentByAttribute, getEventCords} from "@/shared/lib/utils";
export interface IDraggableContext {
isEnabled: boolean,
diff --git a/SteamOrganizer.Web/src/components/primitives/Expander.tsx b/SteamOrganizer.Web/src/shared/ui/Expander.tsx
similarity index 94%
rename from SteamOrganizer.Web/src/components/primitives/Expander.tsx
rename to SteamOrganizer.Web/src/shared/ui/Expander.tsx
index e0e2ccbc..f2975430 100644
--- a/SteamOrganizer.Web/src/components/primitives/Expander.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/Expander.tsx
@@ -1,9 +1,9 @@
import {FC, ReactElement, ReactNode, useState} from "react";
-import {cn} from "@/lib/utils.ts";
+import {cn} from "@/shared/lib/utils";
import {AnimatePresence, motion} from "framer-motion";
import clsx from "clsx";
-import {Icon, SvgIcon} from "src/defines";
-import {uiStore} from "@/store/local.tsx";
+import {Icon, SvgIcon} from "@/defines";
+import {uiStore} from "@/store/local";
interface IExpanderProps {
diff --git a/SteamOrganizer.Web/src/components/primitives/Input.tsx b/SteamOrganizer.Web/src/shared/ui/Input.tsx
similarity index 82%
rename from SteamOrganizer.Web/src/components/primitives/Input.tsx
rename to SteamOrganizer.Web/src/shared/ui/Input.tsx
index 500a5df8..15f04a0d 100644
--- a/SteamOrganizer.Web/src/components/primitives/Input.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/Input.tsx
@@ -1,9 +1,10 @@
-import {type ChangeEvent, forwardRef, type InputHTMLAttributes, type CompositionEvent, useImperativeHandle, useRef} from "react";
-import {cn} from "@/lib/utils.ts";
-import {type TypeInputValidator} from "@/hooks/useFormValidation.ts";
-import {type IBindable} from "@/components/primitives/types/IBindable.ts";
+import {type ChangeEvent, forwardRef, type InputHTMLAttributes, type CompositionEvent } from "react";
+import {cn} from "@/shared/lib/utils";
+import {type TypeInputValidator} from "@/shared/hooks/useFormValidation";
+import {type IBindable} from "@/shared/types/IBindable";
export const enum EPropertyChangedTrigger {
+ None,
OnLostFocus,
Reactive
}
@@ -19,7 +20,7 @@ export interface IInputProps extends InputHTMLAttributes, IBin
const Input = forwardRef((
- { className,bindTo, bindKey, onValidate, validator, onChanged, filter, converter, trigger = EPropertyChangedTrigger.OnLostFocus, ...props}, forwardedRef) => {
+ { className,bindTo, bindKey, onValidate, validator, onChanged, filter, converter, trigger = EPropertyChangedTrigger.None, ...props}, forwardedRef) => {
const onChanging = (value: string) => {
if(bindTo && bindKey && bindTo[bindKey] !== (converter ? (value = converter(value)) : value)) {
@@ -56,7 +57,7 @@ const Input = forwardRef((
return (
) => {
+ onBeforeInput={filter ? (e) => {
if(!filter.test(e.data)) {
e.preventDefault()
}
diff --git a/SteamOrganizer.Web/src/shared/ui/Label/Label.module.css b/SteamOrganizer.Web/src/shared/ui/Label/Label.module.css
new file mode 100644
index 00000000..bb7aaa87
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/Label/Label.module.css
@@ -0,0 +1,3 @@
+.colorful {
+ @apply text-secondary font-bold w-fit bg-tertiary px-3 py-1 rounded-lg;
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/Label/Label.tsx b/SteamOrganizer.Web/src/shared/ui/Label/Label.tsx
new file mode 100644
index 00000000..40effef3
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/Label/Label.tsx
@@ -0,0 +1,26 @@
+import React, {type ComponentProps, forwardRef} from "react";
+import styles from "./Label.module.css";
+import clsx from "clsx";
+
+export const enum ELabelVariant {
+ Primary,
+ Colorful
+}
+
+interface ILabelProps extends ComponentProps<"span"> {
+ children: string;
+ variant?: ELabelVariant;
+}
+
+const variants = {
+ [ELabelVariant.Primary]: "text-foreground-accent font-semibold",
+ [ELabelVariant.Colorful]: styles.colorful
+}
+
+export const Label = forwardRef(({ children, className, variant = ELabelVariant.Primary, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+})
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/components/primitives/Loader.tsx b/SteamOrganizer.Web/src/shared/ui/Loader.tsx
similarity index 66%
rename from SteamOrganizer.Web/src/components/primitives/Loader.tsx
rename to SteamOrganizer.Web/src/shared/ui/Loader.tsx
index c03222fa..35f99935 100644
--- a/SteamOrganizer.Web/src/components/primitives/Loader.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/Loader.tsx
@@ -1,5 +1,6 @@
-import { type FC} from "react";
-import {cn} from "@/lib/utils.ts";
+import {type FC} from "react";
+import {cn} from "@/shared/lib/utils";
+import {ELabelVariant, Label} from "@/shared/ui/Label/Label";
interface ILoaderProps {
className?: string;
@@ -25,8 +26,8 @@ export const Loader: FC = ({ className, size = 54 }) => {
export const LoaderStatic: FC = ({className, text, absolute = false}) => {
return (
-
- {text ?? "Loading . . ."}
+
+
)
}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/components/primitives/Modal.tsx b/SteamOrganizer.Web/src/shared/ui/Modal.tsx
similarity index 99%
rename from SteamOrganizer.Web/src/components/primitives/Modal.tsx
rename to SteamOrganizer.Web/src/shared/ui/Modal.tsx
index 7b1c3a4b..336302fd 100644
--- a/SteamOrganizer.Web/src/components/primitives/Modal.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/Modal.tsx
@@ -1,6 +1,6 @@
import {AnimatePresence, motion} from "framer-motion";
import React, {Dispatch, FC, Fragment, ReactNode, SetStateAction, useEffect, useRef, useState} from "react";
-import {cn} from "@/lib/utils.ts";
+import {cn} from "@/shared/lib/utils";
interface IModalOptions {
onClosing?: () => boolean | undefined;
diff --git a/SteamOrganizer.Web/src/components/primitives/PasswordBox.tsx b/SteamOrganizer.Web/src/shared/ui/PasswordBox.tsx
similarity index 83%
rename from SteamOrganizer.Web/src/components/primitives/PasswordBox.tsx
rename to SteamOrganizer.Web/src/shared/ui/PasswordBox.tsx
index 67e1fa20..fd1c5c3e 100644
--- a/SteamOrganizer.Web/src/components/primitives/PasswordBox.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/PasswordBox.tsx
@@ -1,7 +1,7 @@
-import {forwardRef, InputHTMLAttributes, useState} from "react";
+import {forwardRef, useState} from "react";
import clsx from 'clsx';
-import Input, {IInputProps} from "@/components/primitives/Input.tsx";
-import {Icon, SvgIcon} from "src/defines";
+import Input, { type IInputProps} from "@/shared/ui/Input";
+import {Icon, SvgIcon} from "@/defines";
interface IPasswordBoxProps extends IInputProps { }
diff --git a/SteamOrganizer.Web/src/shared/ui/Popup/Popup.module.css b/SteamOrganizer.Web/src/shared/ui/Popup/Popup.module.css
new file mode 100644
index 00000000..933e9c67
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/Popup/Popup.module.css
@@ -0,0 +1,3 @@
+.popupDefault {
+ @apply absolute bg-accent drop-shadow-md px-2.5 py-1 text-2xs rounded-2xm text-foreground whitespace-pre text-wrap;
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/Popup/Popup.tsx b/SteamOrganizer.Web/src/shared/ui/Popup/Popup.tsx
new file mode 100644
index 00000000..7752733b
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/Popup/Popup.tsx
@@ -0,0 +1,180 @@
+import {AnimatePresence, HTMLMotionProps, motion, Point} from "framer-motion";
+import {cloneElement, forwardRef, MutableRefObject, ReactElement, ReactNode, useImperativeHandle} from "react";
+import {createPortal} from "react-dom";
+import {cn} from "@/shared/lib/utils";
+import { usePopup} from "@/shared/ui/Popup/usePopup";
+import { EPlacement } from "@/shared/ui/Popup/positioning";
+import {IControlledStateOptions} from "@/shared/hooks/useControlledState";
+import styles from "./Popup.module.css";
+
+/**
+ * Type for popup content that can be either a ReactNode or a function returning ReactNode
+ */
+export type ContentType = ReactNode | (() => ReactNode);
+
+/**
+ * Main popup component props
+ */
+export interface IPopupProps extends Omit
, "content">, IControlledStateOptions {
+ /** Offset from the trigger element. @default { x: 5, y: 0 } */
+ offset?: Point;
+ placement?: EPlacement;
+
+ /** Additional CSS classes for the popup container */
+ className?: string;
+
+ /** Trigger element that toggles the popup */
+ children: ReactElement;
+ childrenRef?: MutableRefObject;
+
+ /** Content to be displayed in the popup */
+ content: ContentType;
+
+ /** Additional props to be spread onto the trigger element */
+ triggerProps?: Record;
+
+ /** Variant of the popup */
+ variant?: PopupVariant;
+
+ asToggle?: boolean;
+ timeout?: number;
+}
+
+export const enum PopupVariant {
+ Raw,
+ Default
+}
+
+const popupVariants = {
+ [PopupVariant.Raw]: "z-50 absolute",
+ [PopupVariant.Default]: `z-50 ${styles.popupDefault}`
+}
+
+/**
+ * A base popup component that provides flexible positioning and animation capabilities.
+ * Used as a foundation for context menus, tooltips, and other floating UI components.
+ *
+ * @example
+ * ```tsx
+ * // Simple popup
+ *
+ *
+ *
+ *
+ * // Custom positioned popup
+ * }
+ * alignX={EPlacementX.Left}
+ * alignY={EPlacementY.Top}
+ * offset={{ x: 10, y: 5 }}
+ * >
+ *
+ *
+ *
+ * // With custom trigger props
+ * console.log('hover'),
+ * className: 'custom-trigger'
+ * }}
+ * >
+ *
+ *
+ *
+ * // Using default configurations
+ *
+ *
+ *
+ *
+ * // Controlled state
+ * const [isOpen, setIsOpen] = useState(false);
+ *
+ *
+ *
+ *
+ * ```
+ */
+export const Popup = forwardRef(({
+ children,
+ content,
+ className,
+ triggerProps = {},
+ setState,
+ state,
+ initialState,
+ timeout,
+ childrenRef,
+ asToggle = true,
+ variant = PopupVariant.Default,
+ placement = EPlacement.MiddleRight,
+ onStateChanged, offset,
+ ...props
+ }, ref) => {
+ const {
+ isOpen,
+ toggle,
+ triggerRef,
+ setIsOpen,
+ popupRef,
+ } = usePopup({ setState, state, initialState, onStateChanged, offset, position: placement, timeout })
+
+ useImperativeHandle(ref, () => popupRef.current);
+ useImperativeHandle(childrenRef, () => triggerRef.current)
+
+ const trigger = cloneElement(children, {
+ ref: triggerRef,
+ onClick: asToggle ? toggle : () => setIsOpen(true),
+ ...triggerProps
+ });
+
+ return (
+ <>
+ {trigger}
+ {createPortal(
+
+ {isOpen && (
+
+ {typeof content === 'function' ? content() : content}
+
+ )}
+ ,
+ document.body
+ )}
+ >
+ );
+});
+
+/**
+ * Default configurations for common popup use cases
+ */
+export const popupDefaults = {
+ side: {
+ openDelay: 0,
+ closeDelay: 0,
+ offset: { x: 20, y: 0 },
+ placement: EPlacement.MiddleRight,
+ initial: { opacity: 0, translateX: "-10px" },
+ animate: { opacity: 1, translateX: 0 },
+ exit: { opacity: 0, translateX: "10px" },
+ }
+}
+
+Popup.displayName = 'Popup';
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/Popup/Tooltip.tsx b/SteamOrganizer.Web/src/shared/ui/Popup/Tooltip.tsx
new file mode 100644
index 00000000..8cfc8f1d
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/Popup/Tooltip.tsx
@@ -0,0 +1,178 @@
+import {forwardRef, MouseEvent, useCallback, useEffect, useImperativeHandle, useRef, useState} from "react";
+import {ContentType, IPopupProps, Popup} from "./Popup";
+import {EPlacement} from "@/shared/ui/Popup/positioning";
+
+
+interface ITooltipProps extends Omit {
+ /** Content to be displayed in the tooltip. Can be a ReactNode or a function returning ReactNode */
+ message: ContentType;
+
+ /** Delay in milliseconds before the tooltip appears. @default 200 */
+ openDelay?: number;
+
+ /** Delay in milliseconds before the tooltip disappears. @default 150 */
+ closeDelay?: number;
+
+ /**
+ * When true, allows users to hover over the tooltip content without it disappearing.
+ * Useful for tooltips with interactive content or copyable text.
+ * @default false
+ */
+ canHover?: boolean;
+
+ enabled?: boolean;
+}
+
+/**
+ * A Tooltip component that displays informative text when hovering over an element.
+ * Built on top of the Popup component, it provides additional hover functionality and timing controls.
+ *
+ * @example
+ * // Basic usage
+ *
+ *
+ *
+ *
+ * // With hover capability and custom delays
+ *
+ *
+ *
+ *
+ * // With custom positioning
+ *
+ *
+ *
+ */
+export const Tooltip = forwardRef(({
+ message,
+ openDelay = 200,
+ closeDelay = 150,
+ placement = EPlacement.TopCenter,
+ offset = { x: 5, y: 5 },
+ className = "",
+ children,
+ canHover = false,
+ enabled = true,
+ ...props
+ }, ref) => {
+ const [shouldShow, setShouldShow] = useState(false);
+ const timerRef = useRef();
+ const tooltipRef = useRef(null);
+ const isHoveringTooltip = useRef(false);
+
+ useImperativeHandle(ref, () => tooltipRef.current);
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = undefined;
+ }
+ }, []);
+
+ const handleMouseEnter = useCallback(() => {
+ clearTimer();
+ if(!enabled) {
+ return;
+ }
+ timerRef.current = window.setTimeout(() => {
+ setShouldShow(true);
+ }, openDelay);
+ }, [openDelay, clearTimer, enabled]);
+
+ const handleMouseLeave = useCallback((event: MouseEvent) => {
+ // Checking if the cursor has moved to the tooltip
+ if (canHover && event.relatedTarget instanceof Node && tooltipRef.current?.contains(event.relatedTarget)) {
+ isHoveringTooltip.current = true;
+ return;
+ }
+
+ clearTimer();
+ timerRef.current = window.setTimeout(() => {
+ // Close only if not hovered over the tooltip
+ if (!isHoveringTooltip.current) {
+ setShouldShow(false);
+ }
+ }, closeDelay);
+ }, [closeDelay, clearTimer, canHover]);
+
+ // Handlers for the tooltip itself to prevent it from closing when hovered
+ const handleTooltipMouseEnter = useCallback(() => {
+ if (canHover) {
+ isHoveringTooltip.current = true;
+ clearTimer();
+ }
+ }, [canHover, clearTimer]);
+
+ const handleTooltipMouseLeave = useCallback((event: MouseEvent) => {
+ if (!canHover) {
+ return;
+ }
+
+ isHoveringTooltip.current = false;
+ // Check if the cursor has returned to the trigger
+ const triggerElement = (event.currentTarget as HTMLElement).previousElementSibling;
+ if (!triggerElement?.contains(event.relatedTarget as Node)) {
+ handleMouseLeave(event);
+ }
+ }, [canHover, handleMouseLeave]);
+
+ const contentCallback = useCallback(() => {
+ return
+ {message instanceof Function ? message() : message}
+
+ }, [message])
+
+ /* Without this effect, the following problem could occur:
+ *|
+ *| - The user hovers over the trigger
+ *| - A timer is started to open the tooltip
+ *| - Before the timer expires, the component is removed from the DOM
+ *| - The timer will still fire and try to update the state of a non-existent component
+ */
+ useEffect(() => {
+ return clearTimer;
+ }, [clearTimer]);
+
+ return (
+
+ {children}
+
+ );
+});
+
+export const TooltipConditional = forwardRef((
+ {preventOpen, ...props}, ref) => {
+ return preventOpen ? props.children : ;
+ }
+);
+
+Tooltip.displayName = 'Tooltip';
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/Popup/positioning.ts b/SteamOrganizer.Web/src/shared/ui/Popup/positioning.ts
new file mode 100644
index 00000000..ca01cdc8
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/Popup/positioning.ts
@@ -0,0 +1,184 @@
+import type {Point} from "framer-motion";
+
+/**
+ * Horizontal placement options for popup positioning
+ */
+export const enum EPlacement {
+ // Base alignments (first 4 bits for X)
+ Left = 1 << 0, // 0001
+ Center = 1 << 1, // 0010
+ Right = 1 << 2, // 0100
+ FitX = 1 << 3, // 1000
+
+ // Vertical alignments (next 4 bits for Y)
+ Top = 1 << 4, // 0001 0000
+ Middle = 1 << 5, // 0010 0000
+ Bottom = 1 << 6, // 0100 0000
+ FitY = 1 << 7, // 1000 0000
+
+ // Комбинации для удобства
+ TopLeft = Top | Left,
+ TopCenter = Top | Center,
+ TopRight = Top | Right,
+ TopFit = Top | FitX,
+
+ MiddleLeft = Middle | Left,
+ MiddleCenter = Middle | Center,
+ MiddleRight = Middle | Right,
+ MiddleFit = Middle | FitX,
+
+ BottomLeft = Bottom | Left,
+ BottomCenter = Bottom | Center,
+ BottomRight = Bottom | Right,
+ BottomFit = Bottom | FitX,
+
+ FitLeft = FitY | Left,
+ FitCenter = FitY | Center,
+ FitRight = FitY | Right,
+ Fit = FitY | FitX
+}
+
+interface Position {
+ top: number;
+ left: number;
+ width?: number;
+ height?: number;
+}
+
+
+const calculatePosition = (
+ anchor: DOMRect,
+ popup: DOMRect,
+ placement: EPlacement,
+ offset: Point = { x: 0, y: 0 }
+): Position => {
+ const position: Position = {
+ top: 0,
+ left: 0
+ };
+
+ // Вспомогательные функции для проверки границ
+ const isWithinX = (pos: number) => pos >= 0 && pos + popup.width <= window.innerWidth;
+ const isWithinY = (pos: number) => pos >= 0 && pos + popup.height <= window.innerHeight;
+
+ // Вычисление горизонтальной позиции
+ const calculateX = (): number => {
+ const leftPos = anchor.left - popup.width - offset.x;
+ const rightPos = anchor.right + offset.x;
+ const centerPos = anchor.left + (anchor.width - popup.width) / 2;
+
+ if ((placement & EPlacement.Left) === EPlacement.Left) {
+ return isWithinX(leftPos) ? leftPos :
+ isWithinX(rightPos) ? rightPos : centerPos;
+ }
+
+ if ((placement & EPlacement.Right) === EPlacement.Right) {
+ return isWithinX(rightPos) ? rightPos :
+ isWithinX(leftPos) ? leftPos : centerPos;
+ }
+
+ if ((placement & EPlacement.Center) === EPlacement.Center) {
+ const align = Math.min(window.innerWidth - (centerPos + popup.width), 0);
+ return Math.max(centerPos + align, Math.abs(offset.x));
+ }
+
+ if ((placement & EPlacement.FitX) === EPlacement.FitX) {
+ position.width = anchor.width;
+ return anchor.left;
+ }
+
+ return centerPos;
+ };
+
+ // Вычисление вертикальной позиции
+ const calculateY = (): number => {
+ const topPos = anchor.top - popup.height - offset.y;
+ const bottomPos = anchor.bottom + offset.y;
+ const middlePos = anchor.top + (anchor.height - popup.height) / 2;
+
+ if ((placement & EPlacement.Top) === EPlacement.Top) {
+ return isWithinY(topPos) ? topPos :
+ isWithinY(bottomPos) ? bottomPos : middlePos;
+ }
+
+ if ((placement & EPlacement.Bottom) === EPlacement.Bottom) {
+ return isWithinY(bottomPos) ? bottomPos :
+ isWithinY(topPos) ? topPos : middlePos;
+ }
+
+ if ((placement & EPlacement.Middle) === EPlacement.Middle) {
+ return isWithinY(middlePos) ? middlePos :
+ isWithinY(bottomPos) ? bottomPos - anchor.height / 2 :
+ topPos + anchor.height / 2;
+ }
+
+ if ((placement & EPlacement.FitY) === EPlacement.FitY) {
+ position.height = anchor.height;
+ return anchor.top;
+ }
+
+ return middlePos;
+ };
+
+ position.left = calculateX();
+ position.top = calculateY();
+
+ return position;
+};
+
+/**
+ * Positions a popup element relative to its trigger element with smart overflow handling
+ *
+ * @param trigger - The element that triggers the popup
+ * @param placement -
+ * @param popup - The popup element to be positioned
+ * @param offset - Offset from the calculated position
+ *
+ * @example
+ * ```tsx
+ * // Basic usage
+ * align(
+ * triggerElement,
+ * popupElement,
+ * EPlacementX.Right,
+ * EPlacementY.Center,
+ * { x: 5, y: 0 }
+ * );
+ *
+ * // Center alignment
+ * align(
+ * triggerElement,
+ * popupElement,
+ * EPlacementX.Center,
+ * EPlacementY.Center,
+ * { x: 0, y: 0 }
+ * );
+ * ```
+ */
+export const align = (
+ trigger: HTMLElement,
+ popup: HTMLElement,
+ placement: EPlacement,
+ offset: Point = { x: 0, y: 0 }
+) => {
+ const triggerRect = trigger.getBoundingClientRect();
+ const popupRect = popup.getBoundingClientRect();
+
+ const { top, left, width, height } = calculatePosition(
+ triggerRect,
+ popupRect,
+ placement,
+ offset
+ );
+
+ popup.style.top = `${top}px`;
+ popup.style.left = `${left}px`;
+
+ if (width !== undefined) {
+ popup.style.width = `${width}px`;
+ }
+
+ if (height !== undefined) {
+ popup.style.height = `${height}px`;
+ }
+};
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/Popup/usePopup.ts b/SteamOrganizer.Web/src/shared/ui/Popup/usePopup.ts
new file mode 100644
index 00000000..598cb4e9
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/Popup/usePopup.ts
@@ -0,0 +1,120 @@
+import {useCallback, useEffect, useRef, useState} from "react";
+import {align, EPlacement} from "./positioning";
+import {Point} from "framer-motion";
+import {IControlledStateOptions, useControlledState} from "@/shared/hooks/useControlledState";
+import {getScrollParent} from "@/shared/lib/utils";
+
+/**
+ * Props for usePopup hook
+ */
+interface UsePopupProps extends IControlledStateOptions {
+ position: EPlacement;
+
+ /** Offset from the calculated position. @default { x: 5, y: 0 } */
+ offset?: Point;
+ timeout?: number;
+}
+
+/**
+ * A custom hook that handles popup positioning, state management, and click outside behavior.
+ *
+ * A hook that provides popup functionality including:
+ * - Controlled/uncontrolled state management
+ * - Automatic positioning
+ * - Window resize handling
+ * - Scroll position updates
+ * - Click outside detection
+ *
+ * @returns Object containing state and refs for popup management
+ *
+ * @example
+ * ```tsx
+ * function MyPopup() {
+ * const {
+ * isOpen,
+ * setIsOpen,
+ * toggle,
+ * triggerRef,
+ * popupRef,
+ * } = usePopup({
+ * alignX: EPlacementX.Right,
+ * alignY: EPlacementY.Center,
+ * });
+ *
+ * return (
+ * <>
+ *
+ * {isOpen && (
+ *
+ * Popup Content
+ *
+ * )}
+ * >
+ * );
+ * }
+ * ```
+ */
+export const usePopup = ({
+ position,
+ timeout,
+ offset = { x: 5, y: 0 },
+ ...props
+ }: UsePopupProps) => {
+ const triggerRef = useRef(null);
+ const popupRef = useRef(null);
+
+ /** Controlled state management using useControlledState hook */
+ const { value: isOpen, setValue: setIsOpen } = useControlledState({...props});
+
+ // Position update and click outside handling
+ useEffect(() => {
+ if (!isOpen || !popupRef.current || !triggerRef.current) return;
+
+ if(timeout) {
+ setTimeout(() => setIsOpen(false), timeout)
+ }
+
+ const onScrollChanged = () => setIsOpen(false)
+ const scroller = getScrollParent(triggerRef.current);
+ scroller.addEventListener("scroll", onScrollChanged)
+
+ /** Updates popup position based on trigger position and alignment settings */
+ const updatePosition = () => {
+ if (popupRef.current && triggerRef.current) {
+ align(triggerRef.current, popupRef.current, position, offset);
+ }
+ };
+
+ updatePosition();
+ window.addEventListener('resize', updatePosition);
+
+ /** Handles clicks outside popup and trigger components */
+ const closeOnClickOutside = (e: MouseEvent) => {
+ if (!popupRef.current?.contains(e.target as Node) &&
+ !triggerRef.current?.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('pointerdown', closeOnClickOutside);
+
+ return () => {
+ window.removeEventListener('resize', updatePosition);
+ document.removeEventListener('pointerdown', closeOnClickOutside);
+ scroller.removeEventListener('scroll', onScrollChanged);
+ };
+ }, [isOpen, position, offset, setIsOpen]);
+
+ /** Toggles popup open state */
+ const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen, setIsOpen]);
+
+ return {
+ isOpen,
+ setIsOpen,
+ toggle,
+ triggerRef,
+ popupRef,
+ };
+};
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/RadioButton/RadioButton.module.css b/SteamOrganizer.Web/src/shared/ui/RadioButton/RadioButton.module.css
new file mode 100644
index 00000000..11b30f62
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/RadioButton/RadioButton.module.css
@@ -0,0 +1,21 @@
+.btnBase {
+ @apply relative z-10;
+}
+
+.btn {
+ @apply relative z-10 py-1.5 px-5 text-foreground;
+}
+
+.active {
+ @apply text-foreground-accent;
+}
+
+.indicator {
+ @apply absolute inset-0 bg-secondary;
+}
+
+.group {
+ > button {
+ position: relative;
+ }
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/RadioButton/RadioButton.tsx b/SteamOrganizer.Web/src/shared/ui/RadioButton/RadioButton.tsx
new file mode 100644
index 00000000..bbf77aed
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/RadioButton/RadioButton.tsx
@@ -0,0 +1,164 @@
+import React, {Dispatch, memo, ReactElement, ReactNode, SetStateAction} from "react";
+import {motion} from "framer-motion";
+import { StatefulComponent, useControlledState} from "@/shared/hooks/useControlledState";
+import styles from "./RadioButton.module.css";
+import clsx from "clsx";
+import {withControlledState} from "@/shared/hoc/withControlledState";
+
+type RadioButtonContentCallback = (item: any, index: number, isActive: boolean, setActive: Dispatch) => ReactNode;
+type RadioButtonClickInterceptor = (index: number, setActive: Dispatch) => boolean | void;
+
+/**
+ * Props for the internal RadioButtonContent component
+ */
+interface RadioButtonContentProps {
+ /** Render function for radio button content */
+ children?: RadioButtonContentCallback;
+
+ /** Whether this radio button is currently active */
+ isActive: boolean;
+
+ /** Function to set this radio button as active */
+ setActive: Dispatch>;
+
+ /** Index of this radio button in the group */
+ index: number;
+
+ /** Data item associated with this radio button */
+ item: any;
+
+ /** Custom indicator element to show active state */
+ indicator?: ReactElement;
+
+ /** Optional click interceptor for radio buttons */
+ clickInterceptor?: RadioButtonClickInterceptor;
+ layoutId?: string;
+}
+
+/**
+ * Props for the main RadioButton component
+ * @template T Type of items in the generator array
+ */
+export interface IRadioButtonProps extends Omit, 'children'>{
+ /**
+ * Render function for each radio button
+ * @param item Current item from generator array
+ * @param index Index of current item
+ * @param isActive Whether this item is currently selected
+ */
+ children?: RadioButtonContentCallback;
+ /** Array of items to generate radio buttons from */
+ generator: T[];
+ /** Optional custom indicator element for active state */
+ indicator?: ReactElement;
+
+ /** Optional click interceptor for radio buttons. Return true if event has been handled */
+ clickInterceptor?: RadioButtonClickInterceptor;
+ layoutId?: string;
+}
+
+/**
+ * Internal component for rendering individual radio buttons
+ * Memoized for preventing unnecessary re-renders
+ */
+function RadioButtonContent({
+ children,
+ isActive,
+ setActive,
+ index,
+ item,
+ indicator,
+ layoutId,
+ clickInterceptor
+ }: RadioButtonContentProps) {
+ return (
+
+ );
+}
+
+const MemoizedRadioButtonContent = memo(RadioButtonContent);
+
+
+/**
+ * A customizable radio button group component with animation support
+ *
+ * @template T Type of items in the generator array
+ *
+ * @example
+ * ```tsx
+ * // Basic usage
+ *
+ * {(item, index, isActive) => (
+ *
+ * {item}
+ *
+ * )}
+ *
+ *
+ * // Controlled usage
+ * const [selected, setSelected] = useState(0);
+ *
+ *
+ * {(item, index, isActive) => (
+ *
+ * )}
+ *
+ * ```
+ */
+function RadioButtonBase({
+ children,
+ generator,
+ setState,
+ state,
+ indicator,
+ clickInterceptor,
+ layoutId,
+ className,
+ ...props
+ }: IRadioButtonProps) {
+
+ return (
+
+ {generator.map((item, index) => (
+
+ ))}
+
+ );
+}
+
+export const RadioButton = withControlledState(RadioButtonBase, 0);
diff --git a/SteamOrganizer.Web/src/shared/ui/Tabs.tsx b/SteamOrganizer.Web/src/shared/ui/Tabs.tsx
new file mode 100644
index 00000000..4325bd9e
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/Tabs.tsx
@@ -0,0 +1,36 @@
+import {AnimatePresence, motion} from "framer-motion";
+import React, {Dispatch, forwardRef, type ReactElement, type ReactNode} from "react";
+import {type StatefulComponent, StatefulMotionComponent} from "@/shared/hooks/useControlledState";
+import {withControlledState} from "@/shared/hoc/withControlledState";
+
+interface ITabsProps extends Omit, "children"> {
+ children: ReactNode[] | ((index: number, setActive: Dispatch) => ReactNode) ;
+ navigator: ReactElement>;
+}
+
+const BaseTabs = forwardRef(
+ ({ navigator, children, state, setState, ...props }, ref) => {
+ return (
+ <>
+ {React.cloneElement(navigator, {
+ setState: setState,
+ state: state
+ } satisfies StatefulComponent)}
+
+
+
+ {typeof children === "function" ? children(state, setState) : children[state]}
+
+
+ >
+ );
+ }
+);
+
+export const Tabs = withControlledState(BaseTabs, 0);
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/components/primitives/TextArea.tsx b/SteamOrganizer.Web/src/shared/ui/TextArea.tsx
similarity index 98%
rename from SteamOrganizer.Web/src/components/primitives/TextArea.tsx
rename to SteamOrganizer.Web/src/shared/ui/TextArea.tsx
index c7894e8d..44b3b1e0 100644
--- a/SteamOrganizer.Web/src/components/primitives/TextArea.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/TextArea.tsx
@@ -30,7 +30,7 @@ export const TextArea = forwardRef(({ classN
target.addEventListener("input", resize);
return () => target.removeEventListener("input", resize);
- }, []);
+ }, [autoResize]);
const onBeforeInput = (e) => {
if (!maxRows) {
diff --git a/SteamOrganizer.Web/src/components/primitives/Toast.tsx b/SteamOrganizer.Web/src/shared/ui/Toast.tsx
similarity index 98%
rename from SteamOrganizer.Web/src/components/primitives/Toast.tsx
rename to SteamOrganizer.Web/src/shared/ui/Toast.tsx
index 7f16c599..1677e5dc 100644
--- a/SteamOrganizer.Web/src/components/primitives/Toast.tsx
+++ b/SteamOrganizer.Web/src/shared/ui/Toast.tsx
@@ -1,6 +1,6 @@
import React, {FC, ReactElement, useState} from "react";
import {AnimatePresence, motion} from "framer-motion";
-import {Gradients, Icon, SvgIcon} from "src/defines";
+import {Gradients, Icon, SvgIcon} from "@/defines";
let setToasts: React.Dispatch>;
const variants: [string, ReactElement][] = [
diff --git a/SteamOrganizer.Web/src/components/primitives/ToggleButton.tsx b/SteamOrganizer.Web/src/shared/ui/ToggleButton.tsx
similarity index 100%
rename from SteamOrganizer.Web/src/components/primitives/ToggleButton.tsx
rename to SteamOrganizer.Web/src/shared/ui/ToggleButton.tsx
diff --git a/SteamOrganizer.Web/src/shared/ui/VirtualScroller/BaseVirtualLayout.ts b/SteamOrganizer.Web/src/shared/ui/VirtualScroller/BaseVirtualLayout.ts
new file mode 100644
index 00000000..b97085d2
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/VirtualScroller/BaseVirtualLayout.ts
@@ -0,0 +1,170 @@
+import {Dispatch, SetStateAction} from "react";
+
+export abstract class BaseVirtualLayout {
+ protected readonly scroller: HTMLElement;
+ protected readonly sizer: HTMLDivElement;
+ protected readonly list: HTMLDivElement;
+ private header?: HTMLElement;
+ private isHeaderPinned: boolean;
+ protected readonly chunkSetter: Dispatch>;
+
+ lastScrollTop: number = 0;
+ lastScrollTime: number = 0;
+ public isScrollAsync: boolean = false;
+
+ public source: ArrayLike;
+ public limit: number = 0;
+
+ startRow: number = 0;
+ offsetIndex: number = 0;
+ offsetBefore: number = 0;
+
+ rowGap: number = 0;
+ rowHeight: number = 0;
+
+ colGap: number = 0;
+ colWidth: number = 0;
+
+ public constructor(source: ArrayLike, chunkSetter: Dispatch>,
+ scroller: HTMLElement, sizer: HTMLDivElement, list: HTMLDivElement)
+ {
+ this.chunkSetter = chunkSetter;
+ this.scroller = scroller;
+ this.header = scroller.querySelector(`[virtual-header]`) as HTMLElement;
+ this.source = source;
+ this.sizer = sizer;
+ this.list = list;
+ }
+
+ protected abstract getSizerHeight(): number;
+ public abstract render(): void;
+
+ private getElementHeight(element?: HTMLElement) {
+ if (!element) {
+ return 0;
+ }
+
+ const style = window.getComputedStyle(element);
+ return element.offsetHeight +
+ (parseFloat(style.marginTop) || 0) +
+ (parseFloat(style.marginBottom) || 0);
+ }
+
+ protected calculateSizes() {
+ this.offsetBefore = 0
+/* let element: HTMLElement | null = this.scroller.querySelector(`[virtual-wrapper]`);
+ while (element && element !== this.scroller) {
+ let sibling = element.previousElementSibling;
+ while (sibling) {
+ if (sibling instanceof HTMLElement) {
+ const style = window.getComputedStyle(sibling);
+ this.offsetBefore += sibling.offsetHeight +
+ parseFloat(style.marginTop) +
+ parseFloat(style.marginBottom);
+ }
+ sibling = sibling.previousElementSibling;
+ }
+ element = element.parentElement;
+ }*/
+
+ const wrapperTop = this.scroller.querySelector(`[virtual-wrapper]`)?.getBoundingClientRect().top || 0;
+ const scrollerTop = this.scroller.getBoundingClientRect().top;
+ this.offsetBefore = wrapperTop - scrollerTop + this.scroller.scrollTop + this.getElementHeight(this.header);
+ //console.log(wrapperTop, scrollerTop, this.offsetBefore)
+
+ const sample = this.list.children[0] as HTMLElement;
+
+ const cellStyle = window.getComputedStyle(sample);
+ const listStyle = window.getComputedStyle(this.list);
+
+ const gap = listStyle.gap.indexOf(" ") > -1 ? null : listStyle.gap;
+
+ this.rowGap = parseFloat(gap || listStyle.rowGap) || 0;
+ this.rowHeight = this.getElementHeight(sample) + this.rowGap
+
+ this.colGap = parseFloat(gap || listStyle.columnGap) || 0;
+ this.colWidth = sample.clientWidth +
+ this.colGap +
+ (parseFloat(cellStyle.borderLeftWidth) || 0) +
+ (parseFloat(cellStyle.borderRightWidth) || 0) +
+ (parseFloat(cellStyle.marginLeft) || 0) +
+ (parseFloat(cellStyle.marginRight) || 0);
+ }
+
+ protected calculateSpeed() {
+ const now = performance.now();
+ const currentScrollTop = this.scroller.scrollTop;
+
+ // Calculating speed: distance over time
+ if (this.lastScrollTime !== 0) {
+ const timeDiff = now - this.lastScrollTime;
+ const scrollDiff = Math.abs(currentScrollTop - this.lastScrollTop);
+ this.isScrollAsync = (scrollDiff / timeDiff) > 2.5; // px/ms
+ }
+
+ const isScrollingDown = currentScrollTop > this.lastScrollTop;
+
+ if (!isScrollingDown && this.isHeaderPinned) {
+ this.isHeaderPinned = false;
+ this.header?.classList.remove("sticky");
+ } else if (isScrollingDown && !this.isHeaderPinned) {
+ this.isHeaderPinned = true;
+ this.header?.classList.add("sticky");
+ }
+
+ this.lastScrollTop = currentScrollTop;
+ this.lastScrollTime = now;
+ }
+
+ public updateScrollPadding() {
+ const padding = Math.max(this.startRow * this.rowHeight, 0);
+ this.list.style.paddingTop = `${padding}px`
+ this.list.style.top = `-${padding + this.rowHeight}px`
+ }
+
+ protected renderDefault(getRenderIndex: () => number, getEndIndex: (visibleRows: number) => number) {
+ if(!this.rowHeight) {
+ return;
+ }
+
+ this.calculateSpeed()
+ const scrollTop = this.scroller.scrollTop - this.offsetBefore;
+ const visibleRows = Math.ceil(
+ (this.scroller.clientHeight) /
+ this.rowHeight) + 1;
+ this.startRow = Math.floor(scrollTop / this.rowHeight)
+
+ const renderIndex = Math.max(getRenderIndex(), 0);
+ const endIndex = Math.min(getEndIndex(visibleRows), this.source.length);
+ let limitCount = Math.max(endIndex - renderIndex, this.source.length ? 1 : 0);
+
+ if(isNaN(limitCount) || (this.limit === limitCount && this.offsetIndex === renderIndex)) {
+ return;
+ }
+
+ this.limit = limitCount
+ this.offsetIndex = renderIndex;
+ this.chunkSetter(Array.from({length: limitCount}, (_, i) => i + renderIndex))
+
+ if(this.isScrollAsync) {
+ this.updateScrollPadding()
+ }
+ }
+
+ public refresh(reset?: boolean) {
+ if(reset) {
+ this.offsetIndex = -1;
+ }
+ if(this.list?.children.length) {
+ this.calculateSizes()
+ }
+ if(!this.rowHeight) {
+ return;
+ }
+
+ const height = this.getSizerHeight();
+ this.sizer.style.height = height > 0 ? `${height}px` : null;
+ this.updateScrollPadding()
+ this.render()
+ }
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/VirtualScroller/GridLayout.ts b/SteamOrganizer.Web/src/shared/ui/VirtualScroller/GridLayout.ts
new file mode 100644
index 00000000..2b18be07
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/VirtualScroller/GridLayout.ts
@@ -0,0 +1,21 @@
+
+import { BaseVirtualLayout } from "./BaseVirtualLayout";
+
+export class GridLayout extends BaseVirtualLayout {
+ private columns: number = NaN;
+
+ protected calculateSizes(): void {
+ super.calculateSizes();
+ this.columns = Math.ceil(this.list.clientWidth / this.colWidth);
+ }
+
+ public render(): void {
+ this.renderDefault(() => this.startRow * this.columns,
+ (visibleRows) => (this.startRow * this.columns) + (this.columns * visibleRows))
+ }
+
+ protected getSizerHeight(): number {
+ const rowCount = Math.ceil(this.source?.length / this.columns);
+ return rowCount * this.rowHeight - this.rowGap;
+ }
+}
diff --git a/SteamOrganizer.Web/src/shared/ui/VirtualScroller/StackLayout.ts b/SteamOrganizer.Web/src/shared/ui/VirtualScroller/StackLayout.ts
new file mode 100644
index 00000000..c1ca0884
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/VirtualScroller/StackLayout.ts
@@ -0,0 +1,11 @@
+import { BaseVirtualLayout } from "./BaseVirtualLayout";
+
+export class StackLayout extends BaseVirtualLayout {
+ public render(): void {
+ this.renderDefault(() => this.startRow, (visibleRows) => this.startRow + visibleRows)
+ }
+
+ protected override getSizerHeight(): number {
+ return Math.ceil(this.rowHeight * this.source.length) - this.rowGap;
+ }
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/shared/ui/VirtualScroller/VirtualScroller.tsx b/SteamOrganizer.Web/src/shared/ui/VirtualScroller/VirtualScroller.tsx
new file mode 100644
index 00000000..e6324171
--- /dev/null
+++ b/SteamOrganizer.Web/src/shared/ui/VirtualScroller/VirtualScroller.tsx
@@ -0,0 +1,159 @@
+import React, {
+ Dispatch,
+ FC,
+ MutableRefObject,
+ ReactElement,
+ SetStateAction,
+ useEffect,
+ useRef,
+ useState
+} from "react";
+import {useScrollbar} from "@/shared/hooks/useScrollbar";
+import {BaseVirtualLayout} from "./BaseVirtualLayout";
+import {Observer} from "@/shared/lib/observer/observer";
+import clsx from "clsx";
+
+export type ScrollerInitializer = (MutableRefObject) | ((onScroll: () => void) => HTMLElement);
+type RenderFunction = (object: T, index: number, mediaMatch?: boolean) => ReactElement;
+
+interface IVirtualListProps {
+ collection: Observer;
+ layout: typeof BaseVirtualLayout;
+ onRenderElement: RenderFunction;
+ scroller?: ScrollerInitializer;
+ className?: string;
+ scrollerClassName?: string;
+ withDragMoving?: boolean;
+ emptyIndicator?: ReactElement | (() => ReactElement),
+ onSizeChanging?: (height: number) => boolean;
+}
+
+interface IVirtualGeneratorProps {
+ data: ArrayLike;
+ onRenderElement: RenderFunction;
+ setRef: MutableRefObject>>;
+ layoutRef: MutableRefObject;
+}
+
+const VirtualGenerator: FC = ({ data, onRenderElement,setRef, layoutRef }) => {
+ const [items, setItems] = useState(data?.length ? [0] : [])
+
+ useEffect(() => {
+ setRef.current = setItems
+ }, []);
+
+ if(layoutRef.current?.isScrollAsync === false) {
+ layoutRef.current.updateScrollPadding()
+ }
+
+ return (
+ items.map((i) => onRenderElement(layoutRef.current ? layoutRef.current.source[i] : data![i], i))
+ )
+}
+
+const ListIndicator: FC<{ setRef: MutableRefObject>>, value?: ReactElement }>
+ = ({ value, setRef }) => {
+ const [indicator, setIndicator] = useState(value);
+ setRef.current = setIndicator;
+ return indicator;
+}
+
+const getScrollElement = (scroller: ScrollerInitializer, onRender: () => void): HTMLElement => {
+ if (scroller instanceof Function) {
+ return scroller(onRender);
+ } else {
+ scroller.current.addEventListener("scroll", onRender);
+ return scroller.current;
+ }
+};
+
+export const VirtualScroller = ({
+ collection, layout, scroller,
+ className, onRenderElement, withDragMoving,
+ emptyIndicator, scrollerClassName,
+ onSizeChanging
+ }: IVirtualListProps) => {
+ const [initialized, setInitialized] = useState(collection.value?.length);
+ const layoutRef = useRef(null!);
+ const sizerRef = useRef(null!);
+ const listRef = useRef(null!);
+ const setIndicator = useRef>>(null!);
+ const chunkSetter = useRef>>(null!);
+
+ let info = layoutRef.current!
+
+ const {scrollRef, hostRef} = useScrollbar({scroll: () => info.render()});
+
+ useEffect(() => {
+ const updateIndicator = () => setIndicator.current(collection.value?.length ? null :
+ emptyIndicator instanceof Function ? emptyIndicator() : emptyIndicator);
+
+ const onRender = () => layoutRef.current?.render()
+ const onCollectionChanged = (data: ArrayLike) => {
+ info.source = data ?? info.source;
+
+ setInitialized((prevInitialized) => {
+ updateIndicator()
+ if (!prevInitialized && collection.value?.length) {
+ chunkSetter.current([0]);
+ return 1;
+ }
+ info.refresh(true);
+ return prevInitialized;
+ });
+ };
+
+ let timer = 0;
+ const resizeObserver = new ResizeObserver(() => {
+ clearTimeout(timer)
+ timer = window.setTimeout(() => {
+ const reset = onSizeChanging ? onSizeChanging(sizerRef.current.clientHeight) : false;
+ info.refresh(reset)
+ timer = 0;
+ }, 30)
+ });
+
+ let scrollElement: HTMLElement = scroller ? getScrollElement(scroller, onRender) : scrollRef.current!;
+
+ if(withDragMoving) {
+ scrollElement.setAttribute("drag-scroller","");
+ }
+
+ // @ts-ignore - We are sure that layout is not abstract
+ info = layoutRef.current = new layout(collection.value, chunkSetter.current,
+ scrollElement, sizerRef.current, listRef.current)
+
+ collection.onChanged(onCollectionChanged)
+ resizeObserver.observe(sizerRef.current)
+ updateIndicator()
+
+ return () => {
+ if (typeof scroller !== "function") {
+ scroller?.current.removeEventListener("scroll", onRender);
+ }
+ collection.unsubscribe(onCollectionChanged);
+ resizeObserver.disconnect()
+ }
+ }, [])
+
+ useEffect(() => {
+ if (initialized) {
+ info.refresh()
+ listRef.current.style.opacity = null;
+ }
+ }, [initialized])
+
+ const list =
+
+
+ return scroller ? list :
+ {list}
+}
\ No newline at end of file
diff --git a/SteamOrganizer.Web/src/store/accounts.ts b/SteamOrganizer.Web/src/store/accounts.ts
index ef968af3..8cf3ed5e 100644
--- a/SteamOrganizer.Web/src/store/accounts.ts
+++ b/SteamOrganizer.Web/src/store/accounts.ts
@@ -1,16 +1,16 @@
-import {Account} from "@/entity/account.ts";
-import db from "@/services/indexedDb.ts";
-import {decrypt, encrypt, exportKey, importKey} from "@/services/cryptography.ts";
-import {config, EDecryptResult, saveConfig} from "@/store/config.ts";
-import {ObservableObject} from "@/lib/observer/observableObject.ts";
-import {isAuthorized} from "@/services/gAuth.ts";
-import {storeBackup} from "@/store/backups.ts";
-import {debounce, jsonIgnoreNull} from "@/lib/utils.ts";
-import {toast, ToastVariant} from "@/components/primitives/Toast.tsx";
-import { openAuthPopup} from "@/pages/Modals/Authentication.tsx";
-import {ESavingState, setSavingState} from "@/components/Header/SaveIndicator.tsx";
-import {getPlayerInfoStream} from "@/services/steamApi.ts";
-import {flagStore} from "@/store/local.tsx";
+import {Account} from "@/entity/account";
+import db from "@/shared/services/indexedDb";
+import {decrypt, encrypt, exportKey, importKey} from "@/shared/services/cryptography";
+import {config, EDecryptResult, saveConfig} from "@/store/config";
+import {ObservableObject} from "@/shared/lib/observer/observableObject";
+import {isAuthorized} from "@/shared/services/gAuth";
+import {storeBackup} from "@/store/backups";
+import {debounce, jsonIgnoreNull} from "@/shared/lib/utils";
+import {toast, ToastVariant} from "@/shared/ui/Toast";
+import { openAuthPopup} from "@/pages/Modals/Authentication";
+import {ESavingState, setSavingState} from "@/components/Header/SaveIndicator";
+import {getPlayerInfoStream} from "@/shared/api/steamApi";
+import {flagStore} from "@/store/local";
export const accounts = new ObservableObject(undefined)
export let dbTimestamp: Date | undefined;
@@ -157,6 +157,9 @@ export const loadAccounts = async (bytes: ArrayBuffer | null = null): Promise({
diff --git a/SteamOrganizer.Web/src/types/steamPlayerSummary.ts b/SteamOrganizer.Web/src/types/steamPlayerSummary.ts
index 7996f55d..2e182ca1 100644
--- a/SteamOrganizer.Web/src/types/steamPlayerSummary.ts
+++ b/SteamOrganizer.Web/src/types/steamPlayerSummary.ts
@@ -27,7 +27,7 @@ export type SteamPlayerBans = {
export type SteamGameInfo = {
appId: number;
playtime_forever: number;
- name: number;
+ name: string;
formattedPrice?: string;
}
diff --git a/SteamOrganizer.Web/src/types/uiMetadata.ts b/SteamOrganizer.Web/src/types/uiMetadata.ts
index 22edfa25..71f6654e 100644
--- a/SteamOrganizer.Web/src/types/uiMetadata.ts
+++ b/SteamOrganizer.Web/src/types/uiMetadata.ts
@@ -1,4 +1,4 @@
-import { type ESteamIdType} from "@/lib/steamIdConverter.ts";
+import { type ESteamIdType} from "@/shared/lib/steamIdConverter";
export const enum ESidebarState {
Hidden,
diff --git a/SteamOrganizer.Web/tailwind.config.js b/SteamOrganizer.Web/tailwind.config.js
index 4a345901..202b1ac7 100644
--- a/SteamOrganizer.Web/tailwind.config.js
+++ b/SteamOrganizer.Web/tailwind.config.js
@@ -100,6 +100,9 @@ export default {
'.grad-primary': {
"@apply bg-gradient-to-r from-[#87CEFA] to-[#6c5ecf]": {}
},
+ '.grad-success': {
+ background: "linear-gradient(to right, #26F596 0%, #0499F2 100%)"
+ },
'.invalidate': {
"@apply pointer-events-none bg-danger animate-shaking": {}
},
diff --git a/SteamOrganizer.Web/tsconfig.json b/SteamOrganizer.Web/tsconfig.json
index 6205159e..003c1486 100644
--- a/SteamOrganizer.Web/tsconfig.json
+++ b/SteamOrganizer.Web/tsconfig.json
@@ -9,7 +9,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
+ "allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,