feat: initial LMS frontend with adaptive navigation and routing
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.flet/
|
||||||
|
.idea/
|
||||||
@@ -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
|
||||||
+23
@@ -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()
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import flet as ft
|
||||||
|
|
||||||
|
def articles_page():
|
||||||
|
return ft.Column([
|
||||||
|
ft.Text("บทความ", size=22),
|
||||||
|
ft.Text("รายการบทความจะมาอยู่ตรงนี้"),
|
||||||
|
])
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import flet as ft
|
||||||
|
|
||||||
|
def courses_page():
|
||||||
|
return ft.Column([
|
||||||
|
ft.Text("หลักสูตร", size=22),
|
||||||
|
ft.Text("รายการหลักสูตร")
|
||||||
|
])
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import flet as ft
|
||||||
|
|
||||||
|
def my_courses_page():
|
||||||
|
return ft.Column([
|
||||||
|
ft.Text('หลักสูตรของฉัน', size=22),
|
||||||
|
ft.Text('ข้อมูลหลักสูตรที่ลงทะเบียน')
|
||||||
|
])
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import flet as ft
|
||||||
|
|
||||||
|
def profile_page():
|
||||||
|
return ft.Column([
|
||||||
|
ft.Text("ข้อมูลผู้ใช้งาน", size=22),
|
||||||
|
ft.Text("รายละเอียดข้อมูลผู้ใช้งานจะมาอยู่ตรงนี้"),
|
||||||
|
])
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import flet as ft
|
||||||
|
|
||||||
|
def progress_page():
|
||||||
|
return ft.Column([
|
||||||
|
ft.Text('ความก้าวหน้าของฉัน', size=22),
|
||||||
|
ft.Text('รายละเอียดความก้าวหน้าของฉัน')
|
||||||
|
])
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class AppState:
|
||||||
|
def __init__(self):
|
||||||
|
self.access_token = None
|
||||||
|
self.user = None
|
||||||
|
|
||||||
|
state = AppState()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flet==0.84.0
|
||||||
|
requests==2.33.1
|
||||||
|
python-dotenv==1.2.2
|
||||||
Reference in New Issue
Block a user