commit b4725b184b8bdc12ea2ee5baa655ce5f6fefc897 Author: Flook Date: Mon May 4 17:44:35 2026 +0700 feat: initial LMS frontend with adaptive navigation and routing diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..345bd64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.env +.flet/ +.idea/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..baa368e --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# LMS Frontend + +โปรเจกต์ **lms-frontend** เป็นระบบ Frontend +สำหรับระบบ **Learning Management System (LMS)** + +ระบบนี้ถูกออกแบบเพื่อ: +- รองรับการเรียนรู้ผ่าน Web / Mobile / Desktop +- ใช้โค้ดชุดเดียว (Single Codebase) แต่รองรับหลายแพลตฟอร์ม +- เชื่อมต่อกับ Backend LMS ผ่าน API +- รองรับการพัฒนา UI แบบ Modular และขยายในอนาคต + +--- + +## Purpose + +- เป็น Frontend หลักของระบบ LMS +- แสดงผลข้อมูลการเรียนรู้จาก Backend API +- รองรับผู้ใช้งานบนหลายอุปกรณ์ (Responsive + Adaptive UI) +- ออกแบบให้รองรับการ scale ในระดับ production + +--- + +## Features + +- Login / Logout (JWT Authentication) +- Adaptive Navigation + - Desktop / Web → Sidebar (NavigationRail) + - Mobile → Bottom Navigation (NavigationBar) +- Client-side Routing (ไม่ reload แอป) +- Modular Page Architecture +- Cross-platform UI (Web / Mobile / Desktop) +- รองรับฟอนต์ไทย (Noto Sans Thai) + +--- + +## Technical Overview + +- Frontend Framework: **Flet (Python)** +- Language: **Python 3.11+** +- Backend API: **Django REST API (lms-backend)** +- Authentication: **JWT** +- UI Pattern: **Adaptive Layout + Client-side Routing** +- Font: **Noto Sans Thai** + +--- + +## Project Structure + +```bash +lms-frontend/ +├── main.py # Entry point (routing & app flow) +├── app/ +│ ├── state.py # Global state (auth/session) +│ ├── layout.py # Main layout (menu + content) +│ ├── pages/ # Application pages +│ │ ├── articles.py +│ │ ├── courses.py +│ │ ├── my_courses.py +│ │ ├── progress.py +│ │ └── profile.py +│ └── widgets/ +│ └── adaptive_menu.py # Responsive navigation component +├── assets/ +│ └── fonts/ +│ └── NotoSansThai-Regular.ttf +├── requirements.txt +└── README.md +``` + +## License +- สงวนลิขสิทธิ์สำหรับการใช้งานเพื่อการศึกษาและพัฒนาภายในองค์กรเท่านั้น +- Developed with ❤️ using Flet & Python \ No newline at end of file diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..328b837 --- /dev/null +++ b/app/api.py @@ -0,0 +1,23 @@ +import requests + +API_BASE_URL = "http://127.0.0.1:8000" + +def login(username: str, password: str): + url = f"{API_BASE_URL}/api/auth/login/" + + response = requests.post(url, json={ + "email": username, + "password": password + }) + + response.raise_for_status() + return response.json() + +def get_me(token: str): + url = f"{API_BASE_URL}/api/auth/me/" + response = requests.get( + url, + headers={"Authorization": f"Bearer {token}"} + ) + response.raise_for_status() + return response.json() \ No newline at end of file diff --git a/app/layout.py b/app/layout.py new file mode 100644 index 0000000..4dbb1ac --- /dev/null +++ b/app/layout.py @@ -0,0 +1,30 @@ +import flet as ft + +from app.widgets.adaptive_menu import adaptive_menu + +def main_layout(page: ft.Page, content_container: ft.Container): + print(">>> ENTER main_layout") + menu = adaptive_menu(page) + + # ตรวจสอบโหมด (ป้องกันหน้าจอขาวจาก width=0) + width = page.width if page.width > 0 else 1024 # default ไว้ที่ desktop ก่อนถ้ายังไม่รู้ width + is_mobile = width < 600 + print(f">>> Device mode: {'MOBILE' if is_mobile else 'DESKTOP'} (Width: {page.width})") + + if is_mobile: + return ft.Column( + controls=[ + content_container, + menu + ], + expand=True + ) + else: + return ft.Row( + controls=[ + menu, + ft.VerticalDivider(width=1), + content_container + ], + expand=True + ) \ No newline at end of file diff --git a/app/pages/articles.py b/app/pages/articles.py new file mode 100644 index 0000000..18aa138 --- /dev/null +++ b/app/pages/articles.py @@ -0,0 +1,7 @@ +import flet as ft + +def articles_page(): + return ft.Column([ + ft.Text("บทความ", size=22), + ft.Text("รายการบทความจะมาอยู่ตรงนี้"), + ]) \ No newline at end of file diff --git a/app/pages/courses.py b/app/pages/courses.py new file mode 100644 index 0000000..22a5f5c --- /dev/null +++ b/app/pages/courses.py @@ -0,0 +1,7 @@ +import flet as ft + +def courses_page(): + return ft.Column([ + ft.Text("หลักสูตร", size=22), + ft.Text("รายการหลักสูตร") + ]) \ No newline at end of file diff --git a/app/pages/login.py b/app/pages/login.py new file mode 100644 index 0000000..2e30386 --- /dev/null +++ b/app/pages/login.py @@ -0,0 +1,69 @@ +import flet as ft +import asyncio + +from app.api import login, get_me +from app.state import state + +def login_page(page: ft.Page, on_login_success): + username = ft.TextField( + label="Email", + on_submit=lambda _: handle_login(), # เรียก wrapper + autofocus=True + ) + + password = ft.TextField( + label="Password", + password=True, + can_reveal_password=True, + on_submit=lambda _: handle_login(), # เรียก wrapper + ) + + message = ft.Text() + + # WRAPPER (sync) + def handle_login(e=None): + asyncio.create_task(do_login()) # ยิง async + + # ASYNC ตัวจริง + async def do_login(): + if not username.value or not password.value: + message.value = "กรุณากรอกข้อมูลให้ครบถ้วน" + message.color = ft.Colors.RED_400 + page.update() + return + + try: + message.value = "กำลังตรวจสอบข้อมูล..." + message.color = ft.Colors.BLUE_400 + page.update() + + result = await asyncio.to_thread(login, username.value, password.value) + state.access_token = result["access"] + + state.user = await asyncio.to_thread(get_me, state.access_token) + + await on_login_success() # ไป route + + except Exception as e: + message.value = "เข้าสู่ระบบไม่สำเร็จ" + message.color = ft.Colors.RED_400 + print("LOGIN ERROR:", e) + page.update() + + login_button = ft.FilledButton( + "ล็อกอิน", + on_click=handle_login, # ใช้ wrapper + width=200, + ) + + return ft.Column( + controls=[ + ft.Text("เข้าสู่ระบบ", size=24), + username, + password, + login_button, + message, + ], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=20, + ) \ No newline at end of file diff --git a/app/pages/my_courses.py b/app/pages/my_courses.py new file mode 100644 index 0000000..3955743 --- /dev/null +++ b/app/pages/my_courses.py @@ -0,0 +1,7 @@ +import flet as ft + +def my_courses_page(): + return ft.Column([ + ft.Text('หลักสูตรของฉัน', size=22), + ft.Text('ข้อมูลหลักสูตรที่ลงทะเบียน') + ]) \ No newline at end of file diff --git a/app/pages/profile.py b/app/pages/profile.py new file mode 100644 index 0000000..36d1566 --- /dev/null +++ b/app/pages/profile.py @@ -0,0 +1,7 @@ +import flet as ft + +def profile_page(): + return ft.Column([ + ft.Text("ข้อมูลผู้ใช้งาน", size=22), + ft.Text("รายละเอียดข้อมูลผู้ใช้งานจะมาอยู่ตรงนี้"), + ]) \ No newline at end of file diff --git a/app/pages/progress.py b/app/pages/progress.py new file mode 100644 index 0000000..753b12b --- /dev/null +++ b/app/pages/progress.py @@ -0,0 +1,7 @@ +import flet as ft + +def progress_page(): + return ft.Column([ + ft.Text('ความก้าวหน้าของฉัน', size=22), + ft.Text('รายละเอียดความก้าวหน้าของฉัน') + ]) \ No newline at end of file diff --git a/app/state.py b/app/state.py new file mode 100644 index 0000000..7f07b0d --- /dev/null +++ b/app/state.py @@ -0,0 +1,6 @@ +class AppState: + def __init__(self): + self.access_token = None + self.user = None + +state = AppState() \ No newline at end of file diff --git a/app/widgets/adaptive_menu.py b/app/widgets/adaptive_menu.py new file mode 100644 index 0000000..b72cfa4 --- /dev/null +++ b/app/widgets/adaptive_menu.py @@ -0,0 +1,58 @@ +import flet as ft + +def adaptive_menu(page: ft.Page): + is_mobile = page.width < 600 + + menu_keys = ['articles', 'courses', 'my_courses', 'progress', 'profile', 'logout'] + + async def handle_change(e): + index = int(e.control.selected_index) + key = menu_keys[index] + + if key == "logout": + from app.state import state + state.access_token = None + await page.push_route("/login") # หรือ page.go + return + + route = f"/{menu_keys[index]}" + print(f">>> Navigating to: {route}") + await page.push_route(route) + + # sync selected index กับ route + current_route = page.route.replace("/", "") or "articles" + + if current_route not in menu_keys: + current_route = "articles" + + current_selected_index = menu_keys.index(current_route) + + if is_mobile: + return ft.NavigationBar( + destinations=[ + ft.NavigationBarDestination(icon=ft.Icons.ARTICLE, label='บทความ'), + ft.NavigationBarDestination(icon=ft.Icons.SCHOOL, label='หลักสูตร'), + ft.NavigationBarDestination(icon=ft.Icons.BOOK, label='คอร์สของฉัน'), + ft.NavigationBarDestination(icon=ft.Icons.TRENDING_UP, label='ความคืบหน้า'), + ft.NavigationBarDestination(icon=ft.Icons.PERSON, label='โปรไฟล์'), + ft.NavigationBarDestination(icon=ft.Icons.LOGOUT, label="ออกจากระบบ" + ), + ], + selected_index=current_selected_index, + on_change=handle_change, + ) + else: + return ft.NavigationRail( + destinations=[ + ft.NavigationRailDestination(icon=ft.Icons.ARTICLE, label='บทความ'), + ft.NavigationRailDestination(icon=ft.Icons.SCHOOL, label='หลักสูตร'), + ft.NavigationRailDestination(icon=ft.Icons.BOOK, label='คอร์สของฉัน'), + ft.NavigationRailDestination(icon=ft.Icons.TRENDING_UP, label='ความคืบหน้า'), + ft.NavigationRailDestination(icon=ft.Icons.PERSON, label='โปรไฟล์'), + ft.NavigationRailDestination(icon=ft.Icons.LOGOUT, label="Logout" + ), + ], + selected_index=current_selected_index, + on_change=handle_change, + extended=True, + ) \ No newline at end of file diff --git a/assets/fonts/NotoSansThai-Bold.ttf b/assets/fonts/NotoSansThai-Bold.ttf new file mode 100644 index 0000000..9c6798c Binary files /dev/null and b/assets/fonts/NotoSansThai-Bold.ttf differ diff --git a/assets/fonts/NotoSansThai-Medium.ttf b/assets/fonts/NotoSansThai-Medium.ttf new file mode 100644 index 0000000..aea0450 Binary files /dev/null and b/assets/fonts/NotoSansThai-Medium.ttf differ diff --git a/assets/fonts/NotoSansThai-Regular.ttf b/assets/fonts/NotoSansThai-Regular.ttf new file mode 100644 index 0000000..54f3e29 Binary files /dev/null and b/assets/fonts/NotoSansThai-Regular.ttf differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..e0911a6 --- /dev/null +++ b/main.py @@ -0,0 +1,93 @@ +import flet as ft + +from app.layout import main_layout +from app.pages.login import login_page +from app.pages.articles import articles_page +from app.pages.courses import courses_page +from app.pages.my_courses import my_courses_page +from app.pages.progress import progress_page +from app.pages.profile import profile_page +from app.state import state + +async def main(page: ft.Page): + page.title = "LMS Frontend" + + # register font + page.fonts = { + "NotoSansThai": "assets/fonts/NotoSansThai-Regular.ttf", + "NotoSansThaiMedium": "assets/fonts/NotoSansThai-Medium.ttf", + "NotoSansThaiBold": "assets/fonts/NotoSansThai-Bold.ttf", + } + + page.theme = ft.Theme(font_family="NotoSansThai") + + # GLOBAL CONTAINER (รองรับ Persistent Layout + Dynamic Content) + main_container = ft.Container(expand=True) + + # ROUTES MAP + routes = { + "/articles": articles_page, + "/courses": courses_page, + "/my_courses": my_courses_page, + "/progress": progress_page, + "/profile": profile_page, + } + + # ROUTING + async def route_change(e): + print(">>> route_change:", page.route) + + # กัน user แอบเข้า /main โดยไม่ login + if page.route != "/login" and not state.access_token: + await page.push_route("/login") + return + + # LOGIN + if page.route == "/login": + page.views.clear() + + page.views.append( + ft.View( + route="/login", + controls=[ + login_page(page, on_login_success=go_articles) + ], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + vertical_alignment=ft.MainAxisAlignment.CENTER, + ) + ) + + # MAIN + else: + # สร้าง view ครั้งเดียว + if not page.views or page.views[-1].route != "/main": + page.views.clear() + + page.views.append( + ft.View( + route="/main", + controls=[ + main_layout(page, main_container) + ], + ) + ) + + # เปลี่ยน content อย่างเดียว + if page.route in routes: + main_container.content = routes[page.route]() + else: + main_container.content = ft.Text("404 NOT FOUND") + + page.update() + + # LOGIN SUCCESS + async def go_articles(): + print(">>> GO ARTICLES") + await page.push_route("/articles") + + page.on_route_change = route_change + + # start app + await page.push_route("/login") + +ft.run(main) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..be425cf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flet==0.84.0 +requests==2.33.1 +python-dotenv==1.2.2 \ No newline at end of file