Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28f7db172c | |||
| 2063656f29 | |||
| 592cfa405f | |||
| 0a32cd1d21 | |||
| 4516cf4ec6 | |||
| c6f60d1ca7 | |||
| aa35e97cbb | |||
| 57c142c8ef | |||
| 4843bf1d75 | |||
| d02cf29a1d | |||
| 3b4c9c2529 | |||
| fdf03349cf |
+1
-1
@@ -7,7 +7,7 @@ import tseslint from "typescript-eslint";
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(["dist"]),
|
globalIgnores(["dist", "POC-grass"]),
|
||||||
{
|
{
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/skybox.gltf";
|
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
|
||||||
export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
|
export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
|
||||||
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
|
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
|
||||||
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
|
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RefObject } from "react";
|
|||||||
import type { Object3D } from "three";
|
import type { Object3D } from "three";
|
||||||
import { Octree } from "three/addons/math/Octree.js";
|
import { Octree } from "three/addons/math/Octree.js";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
export function useOctreeGraphNode(
|
export function useOctreeGraphNode(
|
||||||
graphNodeRef: RefObject<Object3D | null>,
|
graphNodeRef: RefObject<Object3D | null>,
|
||||||
@@ -17,16 +18,25 @@ export function useOctreeGraphNode(
|
|||||||
}, [rebuildKey]);
|
}, [rebuildKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
logger.debug("useOctreeGraphNode", "Check", {
|
||||||
|
enabled,
|
||||||
|
octreeBuilt: octreeBuilt.current,
|
||||||
|
hasGraphNode: !!graphNodeRef.current,
|
||||||
|
rebuildKey,
|
||||||
|
});
|
||||||
|
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
const graphNode = graphNodeRef.current;
|
const graphNode = graphNodeRef.current;
|
||||||
if (!enabled || octreeBuilt.current || !graphNode) return;
|
if (!enabled || octreeBuilt.current || !graphNode) return;
|
||||||
octreeBuilt.current = true;
|
octreeBuilt.current = true;
|
||||||
|
|
||||||
|
logger.info("useOctreeGraphNode", "Building octree from graph node");
|
||||||
graphNode.updateMatrixWorld(true);
|
graphNode.updateMatrixWorld(true);
|
||||||
|
|
||||||
const octree = new Octree();
|
const octree = new Octree();
|
||||||
octree.fromGraphNode(graphNode);
|
octree.fromGraphNode(graphNode);
|
||||||
|
logger.info("useOctreeGraphNode", "Octree built, calling onOctreeReady");
|
||||||
onOctreeReady(octree);
|
onOctreeReady(octree);
|
||||||
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function useGraphicsSettings(): GraphicsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSetGraphicsSettings(): (
|
export function useSetGraphicsSettings(): (
|
||||||
graphics: Partial<GraphicsState>
|
graphics: Partial<GraphicsState>,
|
||||||
) => void {
|
) => void {
|
||||||
return useWorldSettingsStore((state) => state.setGraphics);
|
return useWorldSettingsStore((state) => state.setGraphics);
|
||||||
}
|
}
|
||||||
@@ -33,19 +33,19 @@ export function useGrassDensity(): number {
|
|||||||
|
|
||||||
export function useGraphicsSetters() {
|
export function useGraphicsSetters() {
|
||||||
const setDynamicGrass = useWorldSettingsStore(
|
const setDynamicGrass = useWorldSettingsStore(
|
||||||
(state) => state.setDynamicGrass
|
(state) => state.setDynamicGrass,
|
||||||
);
|
);
|
||||||
const setDynamicTrees = useWorldSettingsStore(
|
const setDynamicTrees = useWorldSettingsStore(
|
||||||
(state) => state.setDynamicTrees
|
(state) => state.setDynamicTrees,
|
||||||
);
|
);
|
||||||
const setDynamicClouds = useWorldSettingsStore(
|
const setDynamicClouds = useWorldSettingsStore(
|
||||||
(state) => state.setDynamicClouds
|
(state) => state.setDynamicClouds,
|
||||||
);
|
);
|
||||||
const setShadowsEnabled = useWorldSettingsStore(
|
const setShadowsEnabled = useWorldSettingsStore(
|
||||||
(state) => state.setShadowsEnabled
|
(state) => state.setShadowsEnabled,
|
||||||
);
|
);
|
||||||
const setGrassDensity = useWorldSettingsStore(
|
const setGrassDensity = useWorldSettingsStore(
|
||||||
(state) => state.setGrassDensity
|
(state) => state.setGrassDensity,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import type { Octree } from "three/addons/math/Octree.js";
|
import type { Octree } from "three/addons/math/Octree.js";
|
||||||
import type { SceneMode } from "@/types/debug/debug";
|
import type { SceneMode } from "@/types/debug/debug";
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
interface UseWorldSceneLoadingOptions {
|
interface UseWorldSceneLoadingOptions {
|
||||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||||
@@ -31,10 +32,12 @@ export function useWorldSceneLoading({
|
|||||||
(sceneMode === "physics" && octree !== null);
|
(sceneMode === "physics" && octree !== null);
|
||||||
|
|
||||||
const handleGameMapLoaded = useCallback(() => {
|
const handleGameMapLoaded = useCallback(() => {
|
||||||
|
logger.info("WorldSceneLoading", "GameMap loaded");
|
||||||
setGameMapLoaded(true);
|
setGameMapLoaded(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleGameStageLoaded = useCallback(() => {
|
const handleGameStageLoaded = useCallback(() => {
|
||||||
|
logger.info("WorldSceneLoading", "GameStage loaded");
|
||||||
setGameStageLoaded(true);
|
setGameStageLoaded(true);
|
||||||
onLoadingStateChange?.({
|
onLoadingStateChange?.({
|
||||||
currentStep: "Initialisation gameplay",
|
currentStep: "Initialisation gameplay",
|
||||||
@@ -45,6 +48,7 @@ export function useWorldSceneLoading({
|
|||||||
|
|
||||||
const handleOctreeReady = useCallback(
|
const handleOctreeReady = useCallback(
|
||||||
(nextOctree: Octree) => {
|
(nextOctree: Octree) => {
|
||||||
|
logger.info("WorldSceneLoading", "Octree ready");
|
||||||
setOctree(nextOctree);
|
setOctree(nextOctree);
|
||||||
onLoadingStateChange?.({
|
onLoadingStateChange?.({
|
||||||
currentStep: "Collision prête",
|
currentStep: "Collision prête",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type SceneLoadingChangeHandler,
|
type SceneLoadingChangeHandler,
|
||||||
type SceneLoadingState,
|
type SceneLoadingState,
|
||||||
} from "@/types/world/sceneLoading";
|
} from "@/types/world/sceneLoading";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
||||||
|
|
||||||
@@ -243,8 +244,27 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 50, 100], fov: 50 }}
|
camera={{ position: [0, 50, 100], fov: 50 }}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
gl={{
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
antialias: true,
|
||||||
|
stencil: false,
|
||||||
|
}}
|
||||||
onCreated={({ gl }) => {
|
onCreated={({ gl }) => {
|
||||||
gl.setClearColor("#050505");
|
gl.setClearColor("#050505");
|
||||||
|
|
||||||
|
const canvas = gl.domElement;
|
||||||
|
const handleContextLost = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
logger.error("WebGL", "Context lost - GPU resources exhausted");
|
||||||
|
};
|
||||||
|
const handleContextRestored = () => {
|
||||||
|
logger.info("WebGL", "Context restored");
|
||||||
|
};
|
||||||
|
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||||
|
canvas.addEventListener(
|
||||||
|
"webglcontextrestored",
|
||||||
|
handleContextRestored,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditorSceneLoadingTracker
|
<EditorSceneLoadingTracker
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
INITIAL_SCENE_LOADING_STATE,
|
INITIAL_SCENE_LOADING_STATE,
|
||||||
type SceneLoadingState,
|
type SceneLoadingState,
|
||||||
} from "@/types/world/sceneLoading";
|
} from "@/types/world/sceneLoading";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { World } from "@/world/World";
|
import { World } from "@/world/World";
|
||||||
|
|
||||||
export function HomePage(): React.JSX.Element {
|
export function HomePage(): React.JSX.Element {
|
||||||
@@ -51,11 +52,36 @@ export function HomePage(): React.JSX.Element {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCanvasCreated = useCallback(
|
||||||
|
({ gl }: { gl: THREE.WebGLRenderer }) => {
|
||||||
|
const canvas = gl.domElement;
|
||||||
|
|
||||||
|
const handleContextLost = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
logger.error("WebGL", "Context lost - GPU resources exhausted");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextRestored = () => {
|
||||||
|
logger.info("WebGL", "Context restored");
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||||
|
canvas.addEventListener("webglcontextrestored", handleContextRestored);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HandTrackingProvider>
|
<HandTrackingProvider>
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [85, 60, 85], fov: 42 }}
|
camera={{ position: [85, 60, 85], fov: 42 }}
|
||||||
shadows={{ type: THREE.PCFShadowMap }}
|
shadows={{ type: THREE.PCFShadowMap }}
|
||||||
|
gl={{
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
antialias: true,
|
||||||
|
stencil: false,
|
||||||
|
}}
|
||||||
|
onCreated={handleCanvasCreated}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<World onLoadingStateChange={handleSceneLoadingStateChange} />
|
<World onLoadingStateChange={handleSceneLoadingStateChange} />
|
||||||
|
|||||||
@@ -7,7 +7,36 @@ const HTML_CONTENT_TYPE = "text/html";
|
|||||||
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking"]);
|
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking"]);
|
||||||
type ModelEntry = [modelName: string, modelUrl: string];
|
type ModelEntry = [modelName: string, modelUrl: string];
|
||||||
|
|
||||||
|
let cachedSceneData: SceneData | null = null;
|
||||||
|
let loadingPromise: Promise<SceneData | null> | null = null;
|
||||||
|
|
||||||
export async function loadMapSceneData(): Promise<SceneData | null> {
|
export async function loadMapSceneData(): Promise<SceneData | null> {
|
||||||
|
if (cachedSceneData) {
|
||||||
|
return cachedSceneData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingPromise) {
|
||||||
|
return loadingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingPromise = loadMapSceneDataInternal();
|
||||||
|
cachedSceneData = await loadingPromise;
|
||||||
|
loadingPromise = null;
|
||||||
|
|
||||||
|
return cachedSceneData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMapNodes(): MapNode[] | null {
|
||||||
|
return cachedSceneData?.mapNodes ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMapNodesByName(name: string): MapNode[] {
|
||||||
|
const nodes = cachedSceneData?.mapNodes;
|
||||||
|
if (!nodes) return [];
|
||||||
|
return nodes.filter((node) => node.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMapSceneDataInternal(): Promise<SceneData | null> {
|
||||||
const response = await fetch(MAP_JSON_PATH);
|
const response = await fetch(MAP_JSON_PATH);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
+12
-1
@@ -7,9 +7,12 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
|
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||||
|
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||||
@@ -222,6 +225,8 @@ export function GameMap({
|
|||||||
</ModelErrorBoundary>
|
</ModelErrorBoundary>
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
|
<VegetationSystem />
|
||||||
|
<TerrainModel />
|
||||||
<GameMapCollision
|
<GameMapCollision
|
||||||
buildOctree={buildOctree}
|
buildOctree={buildOctree}
|
||||||
mapReady={mapReady}
|
mapReady={mapReady}
|
||||||
@@ -299,8 +304,14 @@ function ModelInstance({
|
|||||||
const sceneInstance = useClonedObject(scene);
|
const sceneInstance = useClonedObject(scene);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
sceneInstance.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.castShadow = true;
|
||||||
|
child.receiveShadow = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
onLoaded();
|
onLoaded();
|
||||||
}, [onLoaded]);
|
}, [onLoaded, sceneInstance]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<primitive
|
<primitive
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
|
|||||||
import type { MapNode } from "@/types/editor/editor";
|
import type { MapNode } from "@/types/editor/editor";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||||
|
|
||||||
export interface GameMapCollisionNode {
|
export interface GameMapCollisionNode {
|
||||||
@@ -108,6 +109,14 @@ export function GameMapCollision({
|
|||||||
const collisionReady =
|
const collisionReady =
|
||||||
mapReady && settledCollisionNodeCount >= collisionNodes.length;
|
mapReady && settledCollisionNodeCount >= collisionNodes.length;
|
||||||
|
|
||||||
|
logger.debug("GameMapCollision", "State", {
|
||||||
|
mapReady,
|
||||||
|
collisionNodesCount: collisionNodes.length,
|
||||||
|
settledCollisionNodeCount,
|
||||||
|
collisionReady,
|
||||||
|
buildOctree,
|
||||||
|
});
|
||||||
|
|
||||||
const notifyLoaded = useCallback(() => {
|
const notifyLoaded = useCallback(() => {
|
||||||
if (loadedNotifiedRef.current) return;
|
if (loadedNotifiedRef.current) return;
|
||||||
|
|
||||||
@@ -124,6 +133,7 @@ export function GameMapCollision({
|
|||||||
|
|
||||||
const handleOctreeReady = useCallback<OctreeReadyHandler>(
|
const handleOctreeReady = useCallback<OctreeReadyHandler>(
|
||||||
(octree) => {
|
(octree) => {
|
||||||
|
logger.info("GameMapCollision", "Octree built, calling onOctreeReady");
|
||||||
onLoadingStateChange?.({
|
onLoadingStateChange?.({
|
||||||
currentStep: "Collision prête",
|
currentStep: "Collision prête",
|
||||||
progress: 0.92,
|
progress: 0.92,
|
||||||
|
|||||||
+20
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import type { AmbientLight, DirectionalLight } from "three";
|
import type { AmbientLight, DirectionalLight } from "three";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +23,11 @@ import {
|
|||||||
} from "@/data/world/lightingConfig";
|
} from "@/data/world/lightingConfig";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
|
||||||
|
const SHADOW_MAP_SIZE = 2048;
|
||||||
|
const SHADOW_CAMERA_SIZE = 100;
|
||||||
|
const SHADOW_CAMERA_NEAR = 0.5;
|
||||||
|
const SHADOW_CAMERA_FAR = 200;
|
||||||
|
|
||||||
type LightingState = {
|
type LightingState = {
|
||||||
ambientIntensity: number;
|
ambientIntensity: number;
|
||||||
sunIntensity: number;
|
sunIntensity: number;
|
||||||
@@ -37,6 +42,20 @@ export function Lighting(): React.JSX.Element {
|
|||||||
const ambient = useRef<AmbientLight>(null);
|
const ambient = useRef<AmbientLight>(null);
|
||||||
const sun = useRef<DirectionalLight>(null);
|
const sun = useRef<DirectionalLight>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sun.current) return;
|
||||||
|
|
||||||
|
sun.current.shadow.mapSize.width = SHADOW_MAP_SIZE;
|
||||||
|
sun.current.shadow.mapSize.height = SHADOW_MAP_SIZE;
|
||||||
|
sun.current.shadow.camera.left = -SHADOW_CAMERA_SIZE;
|
||||||
|
sun.current.shadow.camera.right = SHADOW_CAMERA_SIZE;
|
||||||
|
sun.current.shadow.camera.top = SHADOW_CAMERA_SIZE;
|
||||||
|
sun.current.shadow.camera.bottom = -SHADOW_CAMERA_SIZE;
|
||||||
|
sun.current.shadow.camera.near = SHADOW_CAMERA_NEAR;
|
||||||
|
sun.current.shadow.camera.far = SHADOW_CAMERA_FAR;
|
||||||
|
sun.current.shadow.camera.updateProjectionMatrix();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useDebugFolder("Lighting", (folder) => {
|
useDebugFolder("Lighting", (folder) => {
|
||||||
folder
|
folder
|
||||||
.add(
|
.add(
|
||||||
|
|||||||
Reference in New Issue
Block a user