update
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { Home, ClipboardList, Layers, Trophy, User } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
// Atelier Mobile tab bar — 5 tabs matching the mobile design.
|
||||||
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
|
// Labels keep the mobile design's playful brevity: Hôm nay / Luyện / Thẻ / Thành tích / Tôi
|
||||||
{ to: '/archivement', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/archivement', exact: false },
|
const TABS = [
|
||||||
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
{ to: '/', label: 'Hôm nay', icon: Home, matchPrefix: '/', exact: true },
|
||||||
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
|
{ to: '/toeic', label: 'Luyện', icon: ClipboardList, matchPrefix: '/toeic', exact: false },
|
||||||
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
|
{ to: '/flash-card', label: 'Thẻ', icon: Layers, matchPrefix: '/flash-card', exact: false },
|
||||||
|
{ to: '/archivement', label: 'Thành tích', icon: Trophy, matchPrefix: '/archivement', exact: false },
|
||||||
|
{ to: '/settings', label: 'Tôi', icon: User, matchPrefix: '/settings', exact: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
function isActive(pathname: string, prefix: string, exact: boolean) {
|
function isActive(pathname: string, prefix: string, exact: boolean) {
|
||||||
@@ -18,22 +21,19 @@ export function MobileNav() {
|
|||||||
const pathname = location.pathname
|
const pathname = location.pathname
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed bottom-0 inset-x-0 lg:hidden bg-white border-t border-slate-200 z-50 flex safe-area-inset-bottom">
|
<nav className="m-tabbar lg:hidden" aria-label="Chuyển màn hình">
|
||||||
{NAV_ITEMS.map((item) => {
|
{TABS.map((tab) => {
|
||||||
const active = isActive(pathname, item.matchPrefix, item.exact)
|
const active = isActive(pathname, tab.matchPrefix, tab.exact)
|
||||||
|
const Icon = tab.icon
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={tab.to}
|
||||||
to={item.to}
|
to={tab.to}
|
||||||
className={cn(
|
className={cn('m-tab', active && 'is-active')}
|
||||||
'flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] text-[11px] font-medium transition-colors',
|
aria-current={active ? 'page' : undefined}
|
||||||
active ? 'text-blue-600' : 'text-slate-400',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
<Icon width={22} height={22} strokeWidth={active ? 2.25 : 1.75} />
|
||||||
{item.icon}
|
<span>{tab.label}</span>
|
||||||
</span>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function Dashboard() {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-20 max-w-6xl mx-auto flex flex-col items-center text-center gap-4">
|
<div className="px-4 lg:px-6 py-20 flex flex-col items-center text-center gap-4">
|
||||||
<div className="at-serif italic text-5xl" style={{ color: 'var(--at-mute-2)' }}>Thành tích</div>
|
<div className="at-serif italic text-5xl" style={{ color: 'var(--at-mute-2)' }}>Thành tích</div>
|
||||||
<p className="max-w-sm" style={{ color: 'var(--at-mute)' }}>
|
<p className="max-w-sm" style={{ color: 'var(--at-mute)' }}>
|
||||||
Đăng nhập để xem streak, XP, Xu và bảng xếp hạng của bạn.
|
Đăng nhập để xem streak, XP, Xu và bảng xếp hạng của bạn.
|
||||||
@@ -114,7 +114,7 @@ export function Dashboard() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
{/* Editorial head */}
|
{/* Editorial head */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuthStore } from '@/store/auth-store'
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||||
import {
|
import {
|
||||||
fetchFlashcardTerms,
|
fetchFlashcardTerms,
|
||||||
fetchUserProgress,
|
fetchUserProgress,
|
||||||
@@ -34,7 +35,9 @@ function speak(word: string) {
|
|||||||
export function FlashCardLearnPage({ listId }: Props) {
|
export function FlashCardLearnPage({ listId }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
|
const openAuthModal = useAuthModalStore(s => s.open)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const isGuest = !user
|
||||||
|
|
||||||
const [isFlipped, setIsFlipped] = useState(false)
|
const [isFlipped, setIsFlipped] = useState(false)
|
||||||
const [currentIdx, setCurrentIdx] = useState(0)
|
const [currentIdx, setCurrentIdx] = useState(0)
|
||||||
@@ -213,6 +216,12 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
setTimeout(advance, 450)
|
setTimeout(advance, 450)
|
||||||
}, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance])
|
}, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance])
|
||||||
|
|
||||||
|
// Jump to a specific card in the deck (no progress write — just navigate)
|
||||||
|
const jumpTo = useCallback((idx: number) => {
|
||||||
|
setCurrentIdx(idx)
|
||||||
|
setIsFlipped(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
@@ -222,14 +231,26 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
setIsFlipped(v => !v)
|
setIsFlipped(v => !v)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isFlipped) return
|
// Arrow nav — works for everyone, no progress write
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (currentIdx > 0) jumpTo(currentIdx - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (currentIdx < sessionTerms.length - 1) jumpTo(currentIdx + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SRS keys — auth users only
|
||||||
|
if (isGuest || !isFlipped) return
|
||||||
if (e.key.toLowerCase() === 'j') handleAnswer('known')
|
if (e.key.toLowerCase() === 'j') handleAnswer('known')
|
||||||
else if (e.key.toLowerCase() === 'k') handleAnswer('hard')
|
else if (e.key.toLowerCase() === 'k') handleAnswer('hard')
|
||||||
else if (e.key.toLowerCase() === 'i') handleAnswer('ignored')
|
else if (e.key.toLowerCase() === 'i') handleAnswer('ignored')
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer])
|
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer, isGuest, jumpTo])
|
||||||
|
|
||||||
const total = sessionTerms.length
|
const total = sessionTerms.length
|
||||||
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
|
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
|
||||||
@@ -309,12 +330,6 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jump to a specific card in the deck (no progress write — just navigate)
|
|
||||||
const jumpTo = (idx: number) => {
|
|
||||||
setCurrentIdx(idx)
|
|
||||||
setIsFlipped(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="atelier fixed top-16 right-0 left-0 lg:left-60 bottom-20 lg:bottom-0 flex flex-col px-4 lg:px-6 py-3 overflow-hidden"
|
className="atelier fixed top-16 right-0 left-0 lg:left-60 bottom-20 lg:bottom-0 flex flex-col px-4 lg:px-6 py-3 overflow-hidden"
|
||||||
@@ -468,8 +483,37 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions — auth users get SRS buttons, guests get prev/next + login CTA */}
|
||||||
<div className="mt-4 w-full" style={{ maxWidth: 420 }}>
|
<div className="mt-4 w-full" style={{ maxWidth: 420 }}>
|
||||||
|
{isGuest ? (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-stretch gap-2.5 w-full">
|
||||||
|
<button
|
||||||
|
onClick={() => currentIdx > 0 && jumpTo(currentIdx - 1)}
|
||||||
|
disabled={currentIdx === 0}
|
||||||
|
className="at-action"
|
||||||
|
style={{ padding: '11px 14px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
<span className="at-kbd">←</span> Trước
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => currentIdx < sessionTerms.length - 1 && jumpTo(currentIdx + 1)}
|
||||||
|
disabled={currentIdx >= sessionTerms.length - 1}
|
||||||
|
className="at-action"
|
||||||
|
style={{ padding: '11px 14px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
Sau <span className="at-kbd">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => openAuthModal('register')}
|
||||||
|
className="at-action at-action-known"
|
||||||
|
style={{ padding: '11px 14px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
Đăng nhập để theo dõi tiến độ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
|
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
|
||||||
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
|
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
|
||||||
Bỏ qua <span className="at-kbd">I</span>
|
Bỏ qua <span className="at-kbd">I</span>
|
||||||
@@ -482,14 +526,17 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
|
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<div className="mt-3 w-full" style={{ maxWidth: 420 }}>
|
<div className="mt-3 w-full" style={{ maxWidth: 420 }}>
|
||||||
<div className="flex items-baseline justify-between mb-1.5 text-[12px] text-[var(--at-mute)]">
|
<div className="flex items-baseline justify-between mb-1.5 text-[12px] text-[var(--at-mute)]">
|
||||||
<span>
|
<span>
|
||||||
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total} ·{' '}
|
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total}
|
||||||
{sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ
|
{!isGuest && (
|
||||||
|
<> · {sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span>
|
<span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -501,7 +548,28 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
|
|
||||||
{/* Right sidebar */}
|
{/* Right sidebar */}
|
||||||
<aside className="hidden lg:flex flex-col gap-3 min-h-0">
|
<aside className="hidden lg:flex flex-col gap-3 min-h-0">
|
||||||
{/* Today stats */}
|
{/* Today stats — auth users; guests see a login nudge */}
|
||||||
|
{isGuest ? (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-4 flex-shrink-0"
|
||||||
|
style={{ background: 'var(--at-brand-soft)', border: '1px solid var(--at-line)' }}
|
||||||
|
>
|
||||||
|
<div className="at-eyebrow mb-1" style={{ fontSize: 11, color: 'var(--at-brand-ink)' }}>Chế độ khách</div>
|
||||||
|
<div
|
||||||
|
className="at-serif"
|
||||||
|
style={{ fontSize: 18, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.2, color: 'var(--at-brand-ink)' }}
|
||||||
|
>
|
||||||
|
Đăng nhập để <i>ghi nhớ</i> tiến độ
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => openAuthModal('register')}
|
||||||
|
className="mt-3 w-full text-[12px] font-semibold py-2 rounded-lg transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: 'var(--at-brand)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
Đăng nhập / Đăng ký
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl p-4 flex-shrink-0"
|
className="rounded-2xl p-4 flex-shrink-0"
|
||||||
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
||||||
@@ -532,6 +600,7 @@ export function FlashCardLearnPage({ listId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cards in deck — compact rows (word only) */}
|
{/* Cards in deck — compact rows (word only) */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function FlashCardListPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div>
|
<div>
|
||||||
<div className="at-eyebrow mb-3">Từ vựng TOEIC</div>
|
<div className="at-eyebrow mb-3">Từ vựng TOEIC</div>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function FlashCardTermsPage({ listId }: Props) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
{/* Editorial head */}
|
{/* Editorial head */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div className="flex items-start gap-4 min-w-0">
|
<div className="flex items-start gap-4 min-w-0">
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function Vocabulary() {
|
|||||||
.reverse()
|
.reverse()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
<div className="px-4 lg:px-6 py-6 page-enter">
|
||||||
{/* Mobile topic chips */}
|
{/* Mobile topic chips */}
|
||||||
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
|
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
|
||||||
<div className="flex gap-2 w-max">
|
<div className="flex gap-2 w-max">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function Home() {
|
|||||||
const firstName = user?.name ?? 'bạn'
|
const firstName = user?.name ?? 'bạn'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
{/* Page head — editorial */}
|
{/* Page head — editorial */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function Settings() {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
<div className="px-4 lg:px-6 py-12 flex flex-col items-center justify-center gap-4 text-center">
|
||||||
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>settings</span>
|
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>settings</span>
|
||||||
<h1 className="text-xl font-bold text-slate-700">Cài đặt</h1>
|
<h1 className="text-xl font-bold text-slate-700">Cài đặt</h1>
|
||||||
<p className="text-slate-400 text-sm max-w-xs">
|
<p className="text-slate-400 text-sm max-w-xs">
|
||||||
@@ -30,7 +30,7 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-6 py-6 max-w-5xl mx-auto">
|
<div className="px-4 lg:px-6 py-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đặt</h1>
|
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đặt</h1>
|
||||||
<p className="text-slate-400 text-sm">Quản lý hồ sơ, mục tiêu học tập và thông báo.</p>
|
<p className="text-slate-400 text-sm">Quản lý hồ sơ, mục tiêu học tập và thông báo.</p>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function ToeicPractice() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
<div className="px-6 py-8 page-enter">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
|
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
|
||||||
<p className="text-slate-500">
|
<p className="text-slate-500">
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function WritingChecker() {
|
|||||||
const wordCount = text.split(/\s+/).filter(Boolean).length
|
const wordCount = text.split(/\s+/).filter(Boolean).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
|
<div className="px-6 lg:px-10 py-10 page-enter">
|
||||||
{/* Editorial page head */}
|
{/* Editorial page head */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function WritingHistory() {
|
|||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="px-4 lg:px-6 pb-10 max-w-6xl mx-auto">
|
<section className="px-4 lg:px-6 pb-10">
|
||||||
<h2 className="text-lg font-bold text-slate-800 mb-4">Lịch sử chấm bài</h2>
|
<h2 className="text-lg font-bold text-slate-800 mb-4">Lịch sử chấm bài</h2>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "./styles/mobile.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen" style={{ background: 'var(--at-paper)' }}>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<main className="lg:ml-60 pt-16 pb-20 lg:pb-0 min-h-screen">
|
{/* Extra bottom padding on mobile to clear the floating tab bar
|
||||||
|
(68px pill + 10px margin + safe-area). */}
|
||||||
|
<main className="lg:ml-60 pt-16 pb-28 lg:pb-0 min-h-screen">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
|
|||||||
295
src/styles/mobile.css
Normal file
295
src/styles/mobile.css
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
/* Mobile UI primitives — Atelier Mobile design.
|
||||||
|
Only kicks in on narrow viewports via `@media (max-width: 880px)`.
|
||||||
|
Uses the existing `--at-*` design tokens; does NOT touch desktop layouts. */
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
/* Mobile page background gradient */
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(60% 60% at 50% 0%, color-mix(in oklab, var(--at-brand) 6%, transparent) 0%, transparent 70%),
|
||||||
|
var(--at-paper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Mobile primitives (usable on any viewport) -------- */
|
||||||
|
|
||||||
|
/* Card — soft surface with hairline border and small shadow */
|
||||||
|
.m-card {
|
||||||
|
background: var(--at-surface);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 17, 20, 0.04);
|
||||||
|
}
|
||||||
|
.m-card + .m-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.m-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Big serif display numbers */
|
||||||
|
.m-num {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
.m-num.xl {
|
||||||
|
font-size: 72px;
|
||||||
|
}
|
||||||
|
.m-num.lg {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
.m-num.md {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eyebrow */
|
||||||
|
.m-eyebrow {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--at-mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chips */
|
||||||
|
.m-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--at-paper-2);
|
||||||
|
color: var(--at-ink-2);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
}
|
||||||
|
.m-chip.brand {
|
||||||
|
background: var(--at-brand-soft);
|
||||||
|
color: var(--at-brand);
|
||||||
|
border-color: color-mix(in oklab, var(--at-brand) 18%, transparent);
|
||||||
|
}
|
||||||
|
.m-chip.good {
|
||||||
|
background: var(--at-good-soft);
|
||||||
|
color: var(--at-good);
|
||||||
|
border-color: color-mix(in oklab, var(--at-good) 20%, transparent);
|
||||||
|
}
|
||||||
|
.m-chip.streak {
|
||||||
|
background: var(--at-streak-soft);
|
||||||
|
color: var(--at-streak);
|
||||||
|
border-color: color-mix(in oklab, var(--at-streak) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.m-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--at-line-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.m-bar > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--at-brand);
|
||||||
|
border-radius: 99px;
|
||||||
|
transition: width 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.m-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--at-surface);
|
||||||
|
color: var(--at-ink);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
transition: transform 0.08s ease;
|
||||||
|
}
|
||||||
|
.m-btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
.m-btn.primary {
|
||||||
|
background: var(--at-ink);
|
||||||
|
color: var(--at-paper);
|
||||||
|
border-color: var(--at-ink);
|
||||||
|
}
|
||||||
|
.m-btn.brand {
|
||||||
|
background: var(--at-brand);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--at-brand);
|
||||||
|
box-shadow: 0 4px 14px -4px color-mix(in oklab, var(--at-brand) 60%, transparent);
|
||||||
|
}
|
||||||
|
.m-btn.block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.m-btn.sm {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section header */
|
||||||
|
.m-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 4px 12px;
|
||||||
|
}
|
||||||
|
.m-section-title {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.m-section-title i {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--at-brand);
|
||||||
|
}
|
||||||
|
.m-section-link {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--at-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topic tile */
|
||||||
|
.m-topic-tile {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--at-surface);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
text-align: left;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
.m-topic-tile:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grids */
|
||||||
|
.m-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.m-grid-3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen transition */
|
||||||
|
.m-fade {
|
||||||
|
animation: mFade 0.35s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
@keyframes mFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Mobile-only responsive tweaks (only < 880px) -------- */
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
/* Tab bar — floating glass pill */
|
||||||
|
.m-tabbar {
|
||||||
|
position: fixed;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: calc(10px + env(safe-area-inset-bottom));
|
||||||
|
height: 68px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: color-mix(in oklab, var(--at-surface) 88%, transparent);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--at-line);
|
||||||
|
box-shadow:
|
||||||
|
0 -2px 14px rgba(15, 17, 20, 0.04),
|
||||||
|
0 14px 32px -12px rgba(15, 17, 20, 0.16);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
z-index: 30;
|
||||||
|
padding: 6px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.m-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--at-mute);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 6px 4px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.m-tab.is-active {
|
||||||
|
color: var(--at-brand);
|
||||||
|
background: var(--at-brand-softer);
|
||||||
|
}
|
||||||
|
.m-tab > svg {
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.m-tab.is-active > svg {
|
||||||
|
transform: translateY(-1px) scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Mobile top bar (floating title) -------- */
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.m-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
background: color-mix(in oklab, var(--at-paper) 92%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--at-line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.m-topbar-title {
|
||||||
|
font-family: var(--at-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 26px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.m-topbar-title i {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--at-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user