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 ( -
+
-
-
+
+
-
+

-
+
-
-

- Study Vault of - UIT +
+

+ + Study Vault of + + + UIT +

-
+
-
+



@@ -524,89 +632,106 @@ function App() {


-

+

Introduction

-
-
-

- About +
+
+

+ About

- - our team + + our team
-
+
{[1, 2, 3, 4].map((item) => ( -
-
+
+
{`Team @@ -617,99 +742,105 @@ function App() {
-
-
- +
+
+ Meet - + our team
- {Object.entries(membersData).map(([role, members]) => ( -
+ {Object.entries(groupedData).map(([role, members]) => ( +

{role}

- +
{members.map((member, index) => (
{member.name}
- +

{member.name} @@ -719,139 +850,164 @@ function App() {

))} -
-