diff --git a/.github/workflows/update_data.yaml b/.github/workflows/update_data.yaml
new file mode 100644
index 0000000..113ccc9
--- /dev/null
+++ b/.github/workflows/update_data.yaml
@@ -0,0 +1,45 @@
+name: Notion to JSON Sync
+
+on:
+ workflow_dispatch: # Run manually when recovery is needed
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install dependencies
+ run: |
+ pip install requests python-slugify urllib3
+
+ - name: Run Sync Script
+ env:
+ NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
+ DATABASE_ID: ${{ secrets.DATABASE_ID }}
+ run: python scripts/fetch-members.py
+
+ - name: Create Pull Request
+ uses: peter-evans/create-pull-request@v6
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ commit-message: "[BOT] Update members from Notion"
+
+ branch: notion-data-update
+ delete-branch: true # Auto delete branch after PR is merged
+ title: "PR: Update members from Notion"
+ base: main
+ body: |
+ member data has been synchronized from Notion. Changes include:
+ - Update file: `src/data/notion_member.json`
+ - Update images in: `public/member_images/`
+ reviewers: thu4n, hlocuwu
diff --git a/notion_cache.json b/notion_cache.json
new file mode 100644
index 0000000..95636a4
--- /dev/null
+++ b/notion_cache.json
@@ -0,0 +1 @@
+{"1aabb9ea-bd14-8003-94cb-e05052c8e884": "2026-01-16T07:24:00.000Z", "1aabb9ea-bd14-801b-b84e-e5e6759bab17": "2026-01-19T15:32:00.000Z", "1aabb9ea-bd14-8034-ac22-eb67957b287c": "2025-12-30T15:43:00.000Z", "1aabb9ea-bd14-804b-980b-d17b3028bda5": "2025-12-27T14:02:00.000Z", "1aabb9ea-bd14-805e-bffa-cad47843be2b": "2025-12-30T16:56:00.000Z", "1aabb9ea-bd14-8064-9817-f1b7ee3cd7d8": "2025-12-30T13:56:00.000Z", "1aabb9ea-bd14-807b-8eec-e2f5da97fc9d": "2026-01-21T16:28:00.000Z", "1aabb9ea-bd14-80b4-9d41-ed343f0f84fb": "2025-12-15T04:50:00.000Z", "1aabb9ea-bd14-80c4-b066-c6241af213d8": "2025-12-30T13:57:00.000Z", "1aabb9ea-bd14-80d4-bcae-c47505f078f7": "2025-12-30T15:03:00.000Z", "1aabb9ea-bd14-80fc-b09d-f119168392b0": "2025-12-26T13:14:00.000Z", "1aabb9ea-bd14-80fe-89eb-f5b5495943ef": "2025-12-28T07:30:00.000Z", "2c9bb9ea-bd14-8111-a197-fd2b924a628c": "2025-12-27T09:20:00.000Z", "2c9bb9ea-bd14-8157-a1b2-e2ba27313a99": "2025-12-29T16:31:00.000Z", "2c9bb9ea-bd14-815b-adb0-c3c3e1227924": "2025-12-29T03:38:00.000Z", "2cabb9ea-bd14-8127-a5b5-d0c4be038074": "2025-12-27T09:20:00.000Z", "2cabb9ea-bd14-8155-867c-c90aef7e6ddf": "2025-12-30T01:35:00.000Z"}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 64e1aef..6295f0e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,7 +26,7 @@
"react-dom": "18.3.1",
"react-helmet-async": "^2.0.5",
"react-icons": "^5.5.0",
- "react-router-dom": "^6.30.1",
+ "react-router-dom": "^6.30.3",
"three": "^0.180.0",
"web-vitals": "^2.1.4"
},
@@ -3529,9 +3529,9 @@
}
},
"node_modules/@remix-run/router": {
- "version": "1.23.0",
- "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
- "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -15804,12 +15804,12 @@
}
},
"node_modules/react-router": {
- "version": "6.30.1",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
- "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": {
- "@remix-run/router": "1.23.0"
+ "@remix-run/router": "1.23.2"
},
"engines": {
"node": ">=14.0.0"
@@ -15819,13 +15819,13 @@
}
},
"node_modules/react-router-dom": {
- "version": "6.30.1",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
- "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": {
- "@remix-run/router": "1.23.0",
- "react-router": "6.30.1"
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
},
"engines": {
"node": ">=14.0.0"
diff --git a/package.json b/package.json
index 4dc0d61..9fac481 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"react-dom": "18.3.1",
"react-helmet-async": "^2.0.5",
"react-icons": "^5.5.0",
- "react-router-dom": "^6.30.1",
+ "react-router-dom": "^6.30.3",
"three": "^0.180.0",
"web-vitals": "^2.1.4"
},
diff --git a/public/index.html b/public/index.html
index b6c9bb2..202f09c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,51 +1,76 @@
-
+
-
+
-
+
-
+
-
-
-
+
+
+
-
+
SVUIT - MMTT
-
+
-
+
-
-
+
+
-
+
-
-
+
+
diff --git a/public/member_images/dang_chi_thanh.jpg b/public/member_images/dang_chi_thanh.jpg
new file mode 100644
index 0000000..fa06e39
Binary files /dev/null and b/public/member_images/dang_chi_thanh.jpg differ
diff --git a/public/member_images/doan_nguyen_lam.jpg b/public/member_images/doan_nguyen_lam.jpg
new file mode 100644
index 0000000..2464844
Binary files /dev/null and b/public/member_images/doan_nguyen_lam.jpg differ
diff --git a/public/member_images/doan_nguyen_minh_thu.jpg b/public/member_images/doan_nguyen_minh_thu.jpg
new file mode 100644
index 0000000..1245b84
Binary files /dev/null and b/public/member_images/doan_nguyen_minh_thu.jpg differ
diff --git a/public/member_images/doan_quoc_an.jpg b/public/member_images/doan_quoc_an.jpg
new file mode 100644
index 0000000..87b4a2c
Binary files /dev/null and b/public/member_images/doan_quoc_an.jpg differ
diff --git a/public/member_images/ly_nhat_minh.jpg b/public/member_images/ly_nhat_minh.jpg
new file mode 100644
index 0000000..2132bd7
Binary files /dev/null and b/public/member_images/ly_nhat_minh.jpg differ
diff --git a/public/member_images/ngo_le_tan_huy.jpg b/public/member_images/ngo_le_tan_huy.jpg
new file mode 100644
index 0000000..313cf87
Binary files /dev/null and b/public/member_images/ngo_le_tan_huy.jpg differ
diff --git a/public/member_images/nguyen_hoang_loc.jpg b/public/member_images/nguyen_hoang_loc.jpg
new file mode 100644
index 0000000..d5555c7
Binary files /dev/null and b/public/member_images/nguyen_hoang_loc.jpg differ
diff --git a/public/member_images/nguyen_thanh_an.jpg b/public/member_images/nguyen_thanh_an.jpg
new file mode 100644
index 0000000..b10df11
Binary files /dev/null and b/public/member_images/nguyen_thanh_an.jpg differ
diff --git a/public/member_images/nguyen_the_lap.jpg b/public/member_images/nguyen_the_lap.jpg
new file mode 100644
index 0000000..68bca1c
Binary files /dev/null and b/public/member_images/nguyen_the_lap.jpg differ
diff --git a/public/member_images/pham_viet_hoang.jpg b/public/member_images/pham_viet_hoang.jpg
new file mode 100644
index 0000000..4d31f83
Binary files /dev/null and b/public/member_images/pham_viet_hoang.jpg differ
diff --git a/public/member_images/son_nguyen_ky_duyen.jpg b/public/member_images/son_nguyen_ky_duyen.jpg
new file mode 100644
index 0000000..5126eed
Binary files /dev/null and b/public/member_images/son_nguyen_ky_duyen.jpg differ
diff --git a/public/member_images/tong_vo_anh_thuan.jpg b/public/member_images/tong_vo_anh_thuan.jpg
new file mode 100644
index 0000000..f8771e3
Binary files /dev/null and b/public/member_images/tong_vo_anh_thuan.jpg differ
diff --git a/public/member_images/tran_cong_hai.jpg b/public/member_images/tran_cong_hai.jpg
new file mode 100644
index 0000000..3f9f0d2
Binary files /dev/null and b/public/member_images/tran_cong_hai.jpg differ
diff --git a/public/member_images/tran_pham_bao_tran.jpg b/public/member_images/tran_pham_bao_tran.jpg
new file mode 100644
index 0000000..7cf0116
Binary files /dev/null and b/public/member_images/tran_pham_bao_tran.jpg differ
diff --git a/public/member_images/trinh_hoai_an.jpg b/public/member_images/trinh_hoai_an.jpg
new file mode 100644
index 0000000..2dd66e8
Binary files /dev/null and b/public/member_images/trinh_hoai_an.jpg differ
diff --git a/public/member_images/truong_do_nhu_quynh.jpg b/public/member_images/truong_do_nhu_quynh.jpg
new file mode 100644
index 0000000..fc92545
Binary files /dev/null and b/public/member_images/truong_do_nhu_quynh.jpg differ
diff --git a/scripts/fetch-members.py b/scripts/fetch-members.py
new file mode 100644
index 0000000..e6d618d
--- /dev/null
+++ b/scripts/fetch-members.py
@@ -0,0 +1,128 @@
+import requests, json, time, os
+from slugify import slugify
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+# --- Set up ---
+NOTION_TOKEN = os.environ.get("NOTION_TOKEN")
+DATABASE_ID = os.environ.get("DATABASE_ID")
+IMAGE_FOLDER = "public/member_images"
+HEADERS = {
+ "Authorization": f"Bearer {NOTION_TOKEN}",
+ "Content-Type": "application/json",
+ "Notion-Version": "2022-06-28",
+}
+# Check required
+if not NOTION_TOKEN or not DATABASE_ID:
+ print("❌ Missing NOTION_TOKEN or DATABASE_ID in environment variables!")
+ exit(1)
+
+# Set up session with retry strategy
+session = requests.Session()
+retry_strategy = Retry(
+ total=3, # Max retries: 3
+ backoff_factor=1, # Wait 1s, 2s, 4s between retries
+ status_forcelist=[429, 500, 502, 503, 504], # Retry on these status codes
+)
+session.mount("https://", HTTPAdapter(max_retries=retry_strategy))
+
+def download_image(url, name, force=False):
+ default_path = '/default.png'
+ if not url: return default_path
+ os.makedirs(IMAGE_FOLDER, exist_ok=True)
+
+ filename = f"{slugify(name, separator='_')}.jpg"
+ save_path = os.path.join(IMAGE_FOLDER, filename)
+ temp_path = save_path + ".tmp" # Use temporary file during download
+ web_path = f"/member_images/{filename}"
+
+ if not force and os.path.exists(save_path) and os.path.getsize(save_path) > 1024:
+ return web_path
+
+ try:
+ # Use session with pre-configured retry strategy
+ with session.get(url, stream=True, timeout=20) as res:
+ res.raise_for_status()
+ # Download chunks and write to temporary file
+ with open(temp_path, 'wb') as f:
+ for chunk in res.iter_content(chunk_size=8192):
+ if chunk: f.write(chunk)
+
+ # Validate if the downloaded file is too small (likely corrupted)
+ if os.path.getsize(temp_path) < 100:
+ raise Exception("File too small, download might have failed")
+
+ # Replace existing file only after successful download
+ if os.path.exists(save_path): os.remove(save_path)
+ os.rename(temp_path, save_path)
+
+ print(f"✅ Downloaded: {filename}")
+ return web_path
+ except Exception as e:
+ if os.path.exists(temp_path): os.remove(temp_path)
+ print(f"❌ Error downloading image for {name}: {e}")
+ return web_path if os.path.exists(save_path) else None
+
+def get_body_image(page_id):
+ try:
+ res = session.get(f"https://api.notion.com/v1/blocks/{page_id}/children", headers=HEADERS)
+ for b in res.json().get("results", []):
+ if b["type"] == "image":
+ img = b["image"]
+ return img["file"]["url"] if img["type"] == "file" else img["external"]["url"]
+ except: pass
+ return None
+
+def process_notion_data():
+ res = session.post(f"https://api.notion.com/v1/databases/{DATABASE_ID}/query", headers=HEADERS)
+ if res.status_code != 200: return print(f"❌ Error: {res.text}")
+
+ pages = res.json().get("results", [])
+ cache_file = "notion_cache.json"
+ final_data = []
+ cache = {}
+ if os.path.exists(cache_file):
+ try:
+ with open(cache_file, "r", encoding="utf-8") as f:
+ cache = json.load(f)
+ except:
+ cache = {}
+ new_cache = {}
+
+ for page in pages:
+ p_id, last_edit = page["id"], page["last_edited_time"]
+ props = page["properties"]
+
+ name_obj = props.get("Your Name", {}).get("title", [])
+ name = name_obj[0]["plain_text"] if name_obj else "unnamed"
+
+ filename = f"{slugify(name, separator='_')}.jpg"
+ local_path = os.path.join(IMAGE_FOLDER, filename)
+
+ # Determine if data has changed or file is missing
+ is_changed = cache.get(p_id) != last_edit
+ is_missing = not os.path.exists(local_path) or os.path.getsize(local_path) < 1024
+
+ if is_changed or is_missing:
+ img_url = get_body_image(p_id)
+ img_path = download_image(img_url, name, force=is_changed)
+ # Update cache only if download was successful
+ new_cache[p_id] = last_edit if img_path else cache.get(p_id)
+ time.sleep(0.2) # Slight delay to avoid Notion rate limiting
+ else:
+ img_path, new_cache[p_id] = f"/member_images/{filename}", last_edit
+
+ final_data.append({
+ "name": name,
+ "role": props.get("Role", {}).get("select", {}).get("name", "N/A"),
+ "image": img_path
+ })
+
+ os.makedirs("src/data", exist_ok=True)
+ with open(cache_file, "w") as f: json.dump(new_cache, f)
+ with open("src/data/notion_member.json", "w", encoding="utf-8") as f:
+ json.dump(final_data, f, ensure_ascii=False, indent=4)
+ print("🚀 completed successfully!")
+
+if __name__ == "__main__":
+ process_notion_data()
\ No newline at end of file
diff --git a/src/App.js b/src/App.js
index 825cba8..e30488a 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,12 +1,39 @@
-import React, { useRef, useEffect, useState, lazy } from 'react';
-import './App.css';
-import membersData from './data/members.json';
-import { performanceConfig } from './config/performance';
+import React, { useRef, useEffect, useState, lazy } from "react";
+import "./App.css";
+import membersData from "./data/notion_member.json";
+import { performanceConfig } from "./config/performance";
+
+const ROLE_ORDER = [
+ "Operations Lead",
+ "Senior Advisor",
+ "Platform Operations",
+ "Social Media",
+ "Website Development",
+];
+
+const groupedData = (() => {
+ const groups = membersData.reduce((acc, item) => {
+ const role = item.role || "Khác";
+ if (!acc[role]) acc[role] = [];
+ acc[role].push(item);
+ return acc;
+ }, {});
+
+ const sortedGroups = {};
+ ROLE_ORDER.forEach((role) => {
+ if (groups[role]) {
+ sortedGroups[role] = groups[role];
+ }
+ });
+ return sortedGroups;
+})();
// Lazy load heavy components
-const GooeyNav = lazy(() => import('./components/GooeyNav/GooeyNav'));
-const InfoCards = lazy(() => import('./components/InfoCards/InfoCards'));
-const FadeInOnScroll = lazy(() => import('./components/FadeInOnScroll/FadeInOnScroll'));
+const GooeyNav = lazy(() => import("./components/GooeyNav/GooeyNav"));
+const InfoCards = lazy(() => import("./components/InfoCards/InfoCards"));
+const FadeInOnScroll = lazy(
+ () => import("./components/FadeInOnScroll/FadeInOnScroll"),
+);
// Custom Cursor Component
const CustomCursor = () => {
@@ -26,34 +53,36 @@ const CustomCursor = () => {
const handleLinkHover = () => {
if (cursorDot.current) {
- cursorDot.current.classList.add('active');
+ cursorDot.current.classList.add("active");
}
};
const handleLinkLeave = () => {
if (cursorDot.current) {
- cursorDot.current.classList.remove('active');
+ cursorDot.current.classList.remove("active");
}
};
// Add event listeners
- window.addEventListener('mousemove', handleMouseMove);
- window.addEventListener('mousedown', handleMouseDown);
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mousedown", handleMouseDown);
// Add hover effects for interactive elements
- const interactiveElements = document.querySelectorAll('a, button, [role="button"], [tabindex]');
- interactiveElements.forEach(el => {
- el.addEventListener('mouseenter', handleLinkHover);
- el.addEventListener('mouseleave', handleLinkLeave);
+ const interactiveElements = document.querySelectorAll(
+ 'a, button, [role="button"], [tabindex]',
+ );
+ interactiveElements.forEach((el) => {
+ el.addEventListener("mouseenter", handleLinkHover);
+ el.addEventListener("mouseleave", handleLinkLeave);
});
return () => {
// Cleanup
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('mousedown', handleMouseDown);
- interactiveElements.forEach(el => {
- el.removeEventListener('mouseenter', handleLinkHover);
- el.removeEventListener('mouseleave', handleLinkLeave);
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mousedown", handleMouseDown);
+ interactiveElements.forEach((el) => {
+ el.removeEventListener("mouseenter", handleLinkHover);
+ el.removeEventListener("mouseleave", handleLinkLeave);
});
};
}, []);
@@ -61,7 +90,7 @@ const CustomCursor = () => {
return (
{
opacity: Math.random() * 0.1 + 0.05,
delay: Math.random() * 5,
duration: Math.random() * 10 + 10,
- blur: Math.random() * 30 + 20
+ blur: Math.random() * 30 + 20,
}));
return (
<>
{randomPositions.slice(0, 4).map((pos, i) => (
-
+ filter: `blur(${pos.blur}px)`,
+ opacity: pos.opacity,
+ animation: `float ${pos.duration}s ease-in-out ${pos.delay}s infinite ${i % 2 ? "reverse" : "alternate"}`,
+ zIndex: -2,
+ pointerEvents: "none",
+ }}
+ />
))}
-
+ backgroundSize: "60px 60px, 60px 60px, 100% 100%",
+ zIndex: 0,
+ pointerEvents: "none",
+ transform: "perspective(500px) rotateX(20deg) translateZ(0)",
+ }}
+ />
{[...Array(50)].map((_, i) => {
const size = Math.random() * 6 + 2;
@@ -129,33 +163,39 @@ const BackgroundDecorations = () => {
const opacity = Math.random() * 0.3 + 0.1;
return (
-
+
);
})}
-
+
>
);
};
@@ -168,8 +208,8 @@ function App() {
// Resource hints for performance
useEffect(() => {
// Add preconnect for external domains
- performanceConfig.resourceHints.forEach(hint => {
- const link = document.createElement('link');
+ performanceConfig.resourceHints.forEach((hint) => {
+ const link = document.createElement("link");
link.rel = hint.rel;
link.href = hint.href;
if (hint.crossOrigin) link.crossOrigin = hint.crossOrigin;
@@ -178,9 +218,9 @@ function App() {
return () => {
// Cleanup on unmount
- performanceConfig.resourceHints.forEach(hint => {
+ performanceConfig.resourceHints.forEach((hint) => {
const links = document.querySelectorAll(`link[href="${hint.href}"]`);
- links.forEach(link => link.remove());
+ links.forEach((link) => link.remove());
});
};
}, []);
@@ -188,30 +228,29 @@ function App() {
const items = [
{ label: "Kho tài liệu", href: "https://svuit.org/mmtt" },
{ label: "Đóng góp", href: "https://svuit.org/mmtt/docs/contribute" },
- { label: "Thông báo", href: "https://svuit.org/mmtt/docs/ThongBao/index" }
+ { label: "Thông báo", href: "https://svuit.org/mmtt/docs/ThongBao/index" },
];
// Register service worker and set up performance monitoring
useEffect(() => {
// Register service worker if enabled
if (performanceConfig.features.serviceWorker) {
- const { register } = require('./utils/serviceWorker');
+ const { register } = require("./utils/serviceWorker");
register({
- onSuccess: () => console.log('ServiceWorker registration successful'),
+ onSuccess: () => console.log("ServiceWorker registration successful"),
onUpdate: (registration) => {
- console.log('New content is available; please refresh.');
- if (window.confirm('New version available! Update now?')) {
+ console.log("New content is available; please refresh.");
+ if (window.confirm("New version available! Update now?")) {
const waitingServiceWorker = registration.waiting;
if (waitingServiceWorker) {
- waitingServiceWorker.postMessage({ type: 'SKIP_WAITING' });
+ waitingServiceWorker.postMessage({ type: "SKIP_WAITING" });
window.location.reload();
}
}
- }
+ },
});
}
-
// Set initial visibility with a small delay
const timer = setTimeout(() => {
setIsVisible(true);
@@ -224,7 +263,7 @@ function App() {
}, []);
useEffect(() => {
- const styleElement = document.createElement('style');
+ const styleElement = document.createElement("style");
styleElement.innerHTML = `
@keyframes float {
0%, 100% {
@@ -264,7 +303,7 @@ function App() {
}
`;
- const cursorStyles = document.createElement('style');
+ const cursorStyles = document.createElement("style");
cursorStyles.innerHTML = `
/* Hide all default cursors */
*, *::before, *::after, a, button, [role="button"], input, textarea, select, [tabindex] {
@@ -298,83 +337,101 @@ function App() {
}, []);
return (
-
+
-
-
+
+
-