857 lines
29 KiB
Markdown
857 lines
29 KiB
Markdown
|
|
# Workspace Improvements Implementation Plan
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** Polish the workspace by removing logo/login button clutter, adding a history toggle, enforcing mandatory login after 3 guest uploads, and adding email verification code to the registration flow.
|
|||
|
|
|
|||
|
|
**Architecture:** Five independent changes across the workspace layer. History toggle state lives in `WorkspacePage` and is passed into `LeftSidebar`. Email verification adds a two-step flow inside `AuthModal` only — no new files needed. All i18n strings go through `translations.ts`.
|
|||
|
|
|
|||
|
|
**Tech Stack:** React 18, TypeScript, Tailwind CSS (utility classes), Lucide React icons, existing `http` client in `lib/api.ts`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## File Map
|
|||
|
|
|
|||
|
|
| File | Change |
|
|||
|
|
|------|--------|
|
|||
|
|
| `src/components/layout/AppNavbar.tsx` | Remove logo icon + text + divider |
|
|||
|
|
| `src/components/LeftSidebar.tsx` | Remove login button; add history toggle prop |
|
|||
|
|
| `src/pages/WorkspacePage.tsx` | Add `historyEnabled` state; pass to sidebar; force mandatory modal after 3rd upload |
|
|||
|
|
| `src/components/AuthModal.tsx` | Add send-code button, countdown, verification code field to signup tab |
|
|||
|
|
| `src/lib/authService.ts` | Add `sendEmailCode()` method; update `register()` to send `code` |
|
|||
|
|
| `src/types/api.ts` | Add `SendEmailCodeRequest`; add `code` to `RegisterRequest` |
|
|||
|
|
| `src/lib/translations.ts` | Add new i18n keys for history toggle, verification code flow |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: Add i18n strings for new features
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/lib/translations.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add new keys to both `en` and `zh` auth sections and sidebar section**
|
|||
|
|
|
|||
|
|
In `src/lib/translations.ts`, find the `en.auth` block and add after `oauthFailed`:
|
|||
|
|
```ts
|
|||
|
|
sendCode: 'Send Code',
|
|||
|
|
resendCode: 'Resend',
|
|||
|
|
codeSent: 'Code sent',
|
|||
|
|
verificationCode: 'Verification Code',
|
|||
|
|
verificationCodePlaceholder: 'Enter 6-digit code',
|
|||
|
|
verificationCodeRequired: 'Please enter the verification code.',
|
|||
|
|
verificationCodeHint: 'Check your inbox for the 6-digit code.',
|
|||
|
|
sendCodeFailed: 'Failed to send verification code, please retry.',
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `en.sidebar`, add after `historyHeader`:
|
|||
|
|
```ts
|
|||
|
|
historyToggle: 'Show History',
|
|||
|
|
historyLoginRequired: 'Login to enable history',
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `zh.auth`, add after `oauthFailed`:
|
|||
|
|
```ts
|
|||
|
|
sendCode: '发送验证码',
|
|||
|
|
resendCode: '重新发送',
|
|||
|
|
codeSent: '验证码已发送',
|
|||
|
|
verificationCode: '验证码',
|
|||
|
|
verificationCodePlaceholder: '请输入 6 位验证码',
|
|||
|
|
verificationCodeRequired: '请输入验证码。',
|
|||
|
|
verificationCodeHint: '请查收邮箱中的 6 位验证码。',
|
|||
|
|
sendCodeFailed: '发送验证码失败,请重试。',
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `zh.sidebar`, add after `historyHeader`:
|
|||
|
|
```ts
|
|||
|
|
historyToggle: '显示历史',
|
|||
|
|
historyLoginRequired: '登录后开启历史记录',
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Commit**
|
|||
|
|
```bash
|
|||
|
|
git add src/lib/translations.ts
|
|||
|
|
git commit -m "feat: add i18n keys for history toggle and email verification"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: Remove logo from AppNavbar
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/components/layout/AppNavbar.tsx:38-50`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Remove logo image, text, and divider — keep only the Home icon link**
|
|||
|
|
|
|||
|
|
Replace the entire `{/* Left: Logo + Home link */}` div (lines 38–51) with:
|
|||
|
|
```tsx
|
|||
|
|
{/* Left: Home link */}
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Link
|
|||
|
|
to="/"
|
|||
|
|
className="flex items-center gap-1.5 px-2 py-1 text-ink-muted hover:text-ink-secondary text-xs font-medium transition-colors rounded-md hover:bg-cream-200/60"
|
|||
|
|
>
|
|||
|
|
<Home size={13} />
|
|||
|
|
<span className="hidden sm:inline">{t.marketing.nav.home}</span>
|
|||
|
|
</Link>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify dev server renders correctly**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm run dev
|
|||
|
|
```
|
|||
|
|
Navigate to `/app`. Confirm the navbar no longer shows the TexPixel icon or text, only the Home link on the left.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
```bash
|
|||
|
|
git add src/components/layout/AppNavbar.tsx
|
|||
|
|
git commit -m "feat: remove logo from workspace navbar"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: Remove login button from LeftSidebar and add history toggle prop
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/components/LeftSidebar.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add `historyEnabled` and `onToggleHistory` to the props interface**
|
|||
|
|
|
|||
|
|
Replace the existing `LeftSidebarProps` interface with:
|
|||
|
|
```ts
|
|||
|
|
interface LeftSidebarProps {
|
|||
|
|
files: FileRecord[];
|
|||
|
|
selectedFileId: string | null;
|
|||
|
|
onFileSelect: (fileId: string) => void;
|
|||
|
|
onUploadClick: () => void;
|
|||
|
|
canUploadAnonymously: boolean;
|
|||
|
|
onRequireAuth: () => void;
|
|||
|
|
isCollapsed: boolean;
|
|||
|
|
onToggleCollapse: () => void;
|
|||
|
|
onUploadFiles: (files: File[]) => void;
|
|||
|
|
hasMore: boolean;
|
|||
|
|
loadingMore: boolean;
|
|||
|
|
onLoadMore: () => void;
|
|||
|
|
historyEnabled: boolean;
|
|||
|
|
onToggleHistory: () => void;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Destructure the two new props in the function signature**
|
|||
|
|
|
|||
|
|
Replace the destructuring block (the function params) to add `historyEnabled` and `onToggleHistory`:
|
|||
|
|
```ts
|
|||
|
|
export default function LeftSidebar({
|
|||
|
|
files,
|
|||
|
|
selectedFileId,
|
|||
|
|
onFileSelect,
|
|||
|
|
onUploadClick,
|
|||
|
|
canUploadAnonymously,
|
|||
|
|
onRequireAuth,
|
|||
|
|
isCollapsed,
|
|||
|
|
onToggleCollapse,
|
|||
|
|
onUploadFiles,
|
|||
|
|
hasMore,
|
|||
|
|
loadingMore,
|
|||
|
|
onLoadMore,
|
|||
|
|
historyEnabled,
|
|||
|
|
onToggleHistory,
|
|||
|
|
}: LeftSidebarProps) {
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Add `Toggle` icon to lucide imports**
|
|||
|
|
|
|||
|
|
Change the import line at the top from:
|
|||
|
|
```ts
|
|||
|
|
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
|
|||
|
|
```
|
|||
|
|
to:
|
|||
|
|
```ts
|
|||
|
|
import { Upload, LogOut, FileText, Clock, ChevronLeft, ChevronRight, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
|
|||
|
|
```
|
|||
|
|
(Remove `LogIn`, `History` — no longer used in expanded view)
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Remove the `showAuthModal` state and the `AuthModal` import/usage from LeftSidebar**
|
|||
|
|
|
|||
|
|
Remove these lines near the top of the function body:
|
|||
|
|
```ts
|
|||
|
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
|||
|
|
```
|
|||
|
|
and the `useEffect` that sets `showAuthModal` to false on auth:
|
|||
|
|
```ts
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (isAuthenticated) {
|
|||
|
|
setShowAuthModal(false);
|
|||
|
|
}
|
|||
|
|
}, [isAuthenticated]);
|
|||
|
|
```
|
|||
|
|
Also remove `import AuthModal from './AuthModal';` at the top of the file, and at the bottom remove the `{showAuthModal && <AuthModal onClose={() => setShowAuthModal(false)} />}` JSX.
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Replace the history header section with a toggle switch**
|
|||
|
|
|
|||
|
|
Find the `{/* Middle Area: History */}` block. Replace the header div (the one with `Clock` icon and `historyHeader` text) with:
|
|||
|
|
```tsx
|
|||
|
|
<div className="flex items-center justify-between text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Clock size={14} />
|
|||
|
|
<span>{t.sidebar.historyHeader}</span>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={onToggleHistory}
|
|||
|
|
className={`relative w-8 h-4 rounded-full transition-colors duration-200 focus:outline-none ${
|
|||
|
|
historyEnabled ? 'bg-blue-500' : 'bg-gray-300'
|
|||
|
|
}`}
|
|||
|
|
title={t.sidebar.historyToggle}
|
|||
|
|
aria-pressed={historyEnabled}
|
|||
|
|
>
|
|||
|
|
<span
|
|||
|
|
className={`absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full shadow transition-transform duration-200 ${
|
|||
|
|
historyEnabled ? 'translate-x-4' : 'translate-x-0'
|
|||
|
|
}`}
|
|||
|
|
/>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Gate the history list behind `historyEnabled`**
|
|||
|
|
|
|||
|
|
Replace the entire `<div ref={listRef} ...>` scrollable list with:
|
|||
|
|
```tsx
|
|||
|
|
<div
|
|||
|
|
ref={listRef}
|
|||
|
|
onScroll={handleScroll}
|
|||
|
|
className="flex-1 overflow-y-auto space-y-1 pr-2 -mr-2 custom-scrollbar"
|
|||
|
|
>
|
|||
|
|
{!historyEnabled ? (
|
|||
|
|
<div className="text-center py-12 text-gray-400 text-sm">
|
|||
|
|
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
|||
|
|
{t.sidebar.historyToggle}
|
|||
|
|
</div>
|
|||
|
|
) : !user ? (
|
|||
|
|
<div className="text-center py-12 text-gray-400 text-sm">
|
|||
|
|
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
|||
|
|
{t.sidebar.historyLoginRequired}
|
|||
|
|
</div>
|
|||
|
|
) : files.length === 0 ? (
|
|||
|
|
<div className="text-center py-12 text-gray-400 text-sm">
|
|||
|
|
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
|||
|
|
{t.sidebar.noHistory}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
{files.map((file) => (
|
|||
|
|
<button
|
|||
|
|
key={file.id}
|
|||
|
|
onClick={() => onFileSelect(file.id)}
|
|||
|
|
className={`w-full p-3 rounded-lg text-left transition-all border group relative ${selectedFileId === file.id
|
|||
|
|
? 'bg-blue-50 border-blue-200 shadow-sm'
|
|||
|
|
: 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-100'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<div className={`p-2 rounded-lg ${selectedFileId === file.id ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
|||
|
|
<FileText size={18} />
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<p className={`text-sm font-medium truncate ${selectedFileId === file.id ? 'text-blue-900' : 'text-gray-700'}`}>
|
|||
|
|
{file.filename}
|
|||
|
|
</p>
|
|||
|
|
<div className="flex items-center gap-2 mt-1">
|
|||
|
|
<span className="text-xs text-gray-400">
|
|||
|
|
{new Date(file.created_at).toLocaleDateString()}
|
|||
|
|
</span>
|
|||
|
|
<span className={`w-1.5 h-1.5 rounded-full ${file.status === 'completed' ? 'bg-green-500' :
|
|||
|
|
file.status === 'processing' ? 'bg-yellow-500' : 'bg-red-500'
|
|||
|
|
}`} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
{loadingMore && (
|
|||
|
|
<div className="flex items-center justify-center py-3 text-gray-400">
|
|||
|
|
<Loader2 size={18} className="animate-spin" />
|
|||
|
|
<span className="ml-2 text-xs">{t.common.loading}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{!hasMore && files.length > 0 && (
|
|||
|
|
<div className="text-center py-3 text-xs text-gray-400">
|
|||
|
|
{t.sidebar.noMore}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Replace the bottom user/login area — remove login button, keep only logged-in user view**
|
|||
|
|
|
|||
|
|
Replace the entire `{/* Bottom Area: User/Login */}` div with:
|
|||
|
|
```tsx
|
|||
|
|
{/* Bottom Area: User info (only shown when logged in) */}
|
|||
|
|
{user && (
|
|||
|
|
<div className="p-4 border-t border-gray-100 bg-gray-50/30">
|
|||
|
|
<div className="flex items-center gap-3 p-2 rounded-lg bg-white border border-gray-100 shadow-sm">
|
|||
|
|
<div className="w-8 h-8 bg-gray-900 rounded-full flex items-center justify-center flex-shrink-0">
|
|||
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|||
|
|
<path d="M12 12C14.21 12 16 10.21 16 8C16 5.79 14.21 4 12 4C9.79 4 8 5.79 8 8C8 10.21 9.79 12 12 12ZM12 14C9.33 14 4 15.34 4 18V20H20V18C20 15.34 14.67 14 12 14Z" fill="white" />
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<p className="text-sm font-medium text-gray-900 truncate">{displayName}</p>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={() => signOut()}
|
|||
|
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
|||
|
|
title={t.common.logout}
|
|||
|
|
>
|
|||
|
|
<LogOut size={16} />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: Fix collapsed view — remove LogIn icon button**
|
|||
|
|
|
|||
|
|
In the `if (isCollapsed)` return block, remove the bottom `<button>` that shows the `LogIn` icon:
|
|||
|
|
```tsx
|
|||
|
|
<button
|
|||
|
|
onClick={() => !user && setShowAuthModal(true)}
|
|||
|
|
className="p-3 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors mt-auto"
|
|||
|
|
title={user ? 'Signed In' : t.common.login}
|
|||
|
|
>
|
|||
|
|
<LogIn size={20} />
|
|||
|
|
</button>
|
|||
|
|
```
|
|||
|
|
Replace with nothing (delete that button entirely). The `isAuthenticated` import can stay for now.
|
|||
|
|
|
|||
|
|
- [ ] **Step 9: Commit**
|
|||
|
|
```bash
|
|||
|
|
git add src/components/LeftSidebar.tsx
|
|||
|
|
git commit -m "feat: remove login button from sidebar, add history toggle"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: Wire history toggle state and mandatory auth in WorkspacePage
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/pages/WorkspacePage.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add `historyEnabled` state**
|
|||
|
|
|
|||
|
|
After the existing `const [loadingMore, setLoadingMore] = useState(false);` line, add:
|
|||
|
|
```ts
|
|||
|
|
const [historyEnabled, setHistoryEnabled] = useState(false);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Add `handleToggleHistory` callback**
|
|||
|
|
|
|||
|
|
After the `openAuthModal` callback, add:
|
|||
|
|
```ts
|
|||
|
|
const handleToggleHistory = useCallback(() => {
|
|||
|
|
if (!historyEnabled) {
|
|||
|
|
// Turning on
|
|||
|
|
if (!user) {
|
|||
|
|
openAuthModal();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setHistoryEnabled(true);
|
|||
|
|
if (!hasLoadedFiles.current) {
|
|||
|
|
hasLoadedFiles.current = true;
|
|||
|
|
loadFiles();
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
setHistoryEnabled(false);
|
|||
|
|
}
|
|||
|
|
}, [historyEnabled, user, openAuthModal]);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Remove auto-load on auth — history is now opt-in**
|
|||
|
|
|
|||
|
|
Find the `useEffect` that auto-calls `loadFiles()` when `user` becomes available:
|
|||
|
|
```ts
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!initializing && user && !hasLoadedFiles.current) {
|
|||
|
|
hasLoadedFiles.current = true;
|
|||
|
|
loadFiles();
|
|||
|
|
}
|
|||
|
|
if (!user) {
|
|||
|
|
hasLoadedFiles.current = false;
|
|||
|
|
setFiles([]);
|
|||
|
|
setSelectedFileId(null);
|
|||
|
|
setCurrentPage(1);
|
|||
|
|
setHasMore(false);
|
|||
|
|
}
|
|||
|
|
}, [initializing, user]);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Replace with (keep the reset on logout, remove the auto-load):
|
|||
|
|
```ts
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!user) {
|
|||
|
|
hasLoadedFiles.current = false;
|
|||
|
|
setHistoryEnabled(false);
|
|||
|
|
setFiles([]);
|
|||
|
|
setSelectedFileId(null);
|
|||
|
|
setCurrentPage(1);
|
|||
|
|
setHasMore(false);
|
|||
|
|
}
|
|||
|
|
}, [user]);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Make the auth modal mandatory after 3rd upload**
|
|||
|
|
|
|||
|
|
Add a new state after `showAuthModal`:
|
|||
|
|
```ts
|
|||
|
|
const [authModalMandatory, setAuthModalMandatory] = useState(false);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Change `openAuthModal` to accept an optional `mandatory` parameter:
|
|||
|
|
```ts
|
|||
|
|
const openAuthModal = useCallback((mandatory = false) => {
|
|||
|
|
setAuthModalMandatory(mandatory);
|
|||
|
|
setShowAuthModal(true);
|
|||
|
|
}, []);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
In `handleUpload`, after `incrementGuestUsage()`, add a mandatory modal trigger when the new count hits the limit:
|
|||
|
|
```ts
|
|||
|
|
if (!user && successfulUploads > 0) {
|
|||
|
|
incrementGuestUsage();
|
|||
|
|
// Force login after hitting the limit
|
|||
|
|
setGuestUsageCount(prev => {
|
|||
|
|
const next = prev + 1;
|
|||
|
|
if (next >= GUEST_USAGE_LIMIT) {
|
|||
|
|
openAuthModal(true);
|
|||
|
|
}
|
|||
|
|
return next;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Wait — `incrementGuestUsage` already increments. We need to check the new count after increment. Replace the `if (!user && successfulUploads > 0)` block at the end of `handleUpload` with:
|
|||
|
|
```ts
|
|||
|
|
if (!user && successfulUploads > 0) {
|
|||
|
|
const nextCount = guestUsageCount + successfulUploads;
|
|||
|
|
const newCount = Math.min(nextCount, GUEST_USAGE_LIMIT + 10);
|
|||
|
|
localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(newCount));
|
|||
|
|
setGuestUsageCount(newCount);
|
|||
|
|
if (newCount >= GUEST_USAGE_LIMIT) {
|
|||
|
|
openAuthModal(true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
And remove the separate `incrementGuestUsage` call entirely (delete the `incrementGuestUsage` callback too).
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Update the two places that check upload limit to pass `mandatory=false` (keep non-mandatory for pre-upload checks)**
|
|||
|
|
|
|||
|
|
The `onUploadClick` handler and paste handler already call `openAuthModal()` with no arg (defaults to `false`) — that stays as-is.
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Update AuthModal JSX to pass `mandatory` prop and update close handler**
|
|||
|
|
|
|||
|
|
Find `{showAuthModal && (<AuthModal onClose={() => setShowAuthModal(false)} />)}` and replace with:
|
|||
|
|
```tsx
|
|||
|
|
{showAuthModal && (
|
|||
|
|
<AuthModal
|
|||
|
|
onClose={() => { setShowAuthModal(false); setAuthModalMandatory(false); }}
|
|||
|
|
mandatory={authModalMandatory}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Pass `historyEnabled` and `onToggleHistory` to LeftSidebar**
|
|||
|
|
|
|||
|
|
Find the `<LeftSidebar` JSX and add the two new props:
|
|||
|
|
```tsx
|
|||
|
|
historyEnabled={historyEnabled}
|
|||
|
|
onToggleHistory={handleToggleHistory}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: After login via mandatory modal, auto-enable history**
|
|||
|
|
|
|||
|
|
In the `useEffect` that watches `user`, add history auto-enable when user logs in while `historyEnabled` is still false — actually simpler to auto-enable history when user is set and they just came from a mandatory flow. Instead, just handle this in the `useEffect` that watches user change after modal closes:
|
|||
|
|
|
|||
|
|
Replace the logout-only effect from Step 3 with:
|
|||
|
|
```ts
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (user && !hasLoadedFiles.current && historyEnabled) {
|
|||
|
|
// user logged in while history was already toggled on
|
|||
|
|
hasLoadedFiles.current = true;
|
|||
|
|
loadFiles();
|
|||
|
|
}
|
|||
|
|
if (!user) {
|
|||
|
|
hasLoadedFiles.current = false;
|
|||
|
|
setHistoryEnabled(false);
|
|||
|
|
setFiles([]);
|
|||
|
|
setSelectedFileId(null);
|
|||
|
|
setCurrentPage(1);
|
|||
|
|
setHasMore(false);
|
|||
|
|
}
|
|||
|
|
}, [user]);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 9: Commit**
|
|||
|
|
```bash
|
|||
|
|
git add src/pages/WorkspacePage.tsx
|
|||
|
|
git commit -m "feat: history toggle state, mandatory auth after 3 guest uploads"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: Add email verification to API types and authService
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/types/api.ts`
|
|||
|
|
- Modify: `src/lib/authService.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Update `RegisterRequest` and add `SendEmailCodeRequest` in `src/types/api.ts`**
|
|||
|
|
|
|||
|
|
Find the existing `RegisterRequest` interface:
|
|||
|
|
```ts
|
|||
|
|
export interface RegisterRequest {
|
|||
|
|
email: string;
|
|||
|
|
password: string;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
Replace with:
|
|||
|
|
```ts
|
|||
|
|
export interface RegisterRequest {
|
|||
|
|
email: string;
|
|||
|
|
password: string;
|
|||
|
|
code: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface SendEmailCodeRequest {
|
|||
|
|
email: string;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Add `sendEmailCode()` to `authService` in `src/lib/authService.ts`**
|
|||
|
|
|
|||
|
|
Add the import of `SendEmailCodeRequest` — it will be used below. Then add a new method inside `export const authService = { ... }` after the `login` method:
|
|||
|
|
```ts
|
|||
|
|
async sendEmailCode(email: string): Promise<void> {
|
|||
|
|
await http.post<null>('/user/email/code', { email } satisfies SendEmailCodeRequest, { skipAuth: true });
|
|||
|
|
},
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Also update the `register` method signature to accept `code`:
|
|||
|
|
```ts
|
|||
|
|
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
|||
|
|
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
|
|||
|
|
|
|||
|
|
if (!response.data) {
|
|||
|
|
throw new ApiError(-1, '注册失败,请重试');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return buildSession(response.data, credentials.email);
|
|||
|
|
},
|
|||
|
|
```
|
|||
|
|
(Signature stays the same — just ensuring `credentials` now includes `code` via the updated type.)
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Update the import in `authService.ts` to include `SendEmailCodeRequest`**
|
|||
|
|
|
|||
|
|
Find:
|
|||
|
|
```ts
|
|||
|
|
import type {
|
|||
|
|
AuthData,
|
|||
|
|
GoogleAuthUrlData,
|
|||
|
|
GoogleOAuthCallbackRequest,
|
|||
|
|
LoginRequest,
|
|||
|
|
RegisterRequest,
|
|||
|
|
UserInfoData,
|
|||
|
|
UserInfo,
|
|||
|
|
} from '../types/api';
|
|||
|
|
```
|
|||
|
|
Replace with:
|
|||
|
|
```ts
|
|||
|
|
import type {
|
|||
|
|
AuthData,
|
|||
|
|
GoogleAuthUrlData,
|
|||
|
|
GoogleOAuthCallbackRequest,
|
|||
|
|
LoginRequest,
|
|||
|
|
RegisterRequest,
|
|||
|
|
SendEmailCodeRequest,
|
|||
|
|
UserInfoData,
|
|||
|
|
UserInfo,
|
|||
|
|
} from '../types/api';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Commit**
|
|||
|
|
```bash
|
|||
|
|
git add src/types/api.ts src/lib/authService.ts
|
|||
|
|
git commit -m "feat: add sendEmailCode API and code field to RegisterRequest"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: Update AuthContext to pass `code` through signUp
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/contexts/AuthContext.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Update `signUp` to accept and forward `code`**
|
|||
|
|
|
|||
|
|
Find the `signUp` function/action in `AuthContext.tsx`. It currently calls `authService.register({ email, password })`. Update the `signUp` signature to accept a third `code` parameter and pass it:
|
|||
|
|
```ts
|
|||
|
|
signUp: async (email: string, password: string, code: string) => { ... }
|
|||
|
|
```
|
|||
|
|
Inside, change:
|
|||
|
|
```ts
|
|||
|
|
await authService.register({ email, password, code });
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Also update the `AuthContextValue` interface (or wherever `signUp` type is declared) to:
|
|||
|
|
```ts
|
|||
|
|
signUp: (email: string, password: string, code: string) => Promise<{ error: string | null }>;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Commit**
|
|||
|
|
```bash
|
|||
|
|
git add src/contexts/AuthContext.tsx
|
|||
|
|
git commit -m "feat: thread verification code through AuthContext signUp"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: Add email verification UI to AuthModal
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/components/AuthModal.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add verification code state variables**
|
|||
|
|
|
|||
|
|
After the existing `useState` declarations in `AuthModal`, add:
|
|||
|
|
```ts
|
|||
|
|
const [verificationCode, setVerificationCode] = useState('');
|
|||
|
|
const [codeSent, setCodeSent] = useState(false);
|
|||
|
|
const [codeCountdown, setCodeCountdown] = useState(0);
|
|||
|
|
const [sendingCode, setSendingCode] = useState(false);
|
|||
|
|
const countdownRef = useRef<NodeJS.Timeout | null>(null);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add `useRef` to the React import if not already there.
|
|||
|
|
|
|||
|
|
Also import `authService` at the top:
|
|||
|
|
```ts
|
|||
|
|
import { authService } from '../lib/authService';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Add `sendCode` handler**
|
|||
|
|
|
|||
|
|
After the `handleGoogleOAuth` function, add:
|
|||
|
|
```ts
|
|||
|
|
const handleSendCode = async () => {
|
|||
|
|
const normalizedEmail = email.trim();
|
|||
|
|
if (!normalizedEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
|||
|
|
setFieldErrors(prev => ({ ...prev, email: t.auth.emailInvalid }));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setSendingCode(true);
|
|||
|
|
try {
|
|||
|
|
await authService.sendEmailCode(normalizedEmail);
|
|||
|
|
setCodeSent(true);
|
|||
|
|
setCodeCountdown(60);
|
|||
|
|
countdownRef.current = setInterval(() => {
|
|||
|
|
setCodeCountdown(prev => {
|
|||
|
|
if (prev <= 1) {
|
|||
|
|
clearInterval(countdownRef.current!);
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
return prev - 1;
|
|||
|
|
});
|
|||
|
|
}, 1000);
|
|||
|
|
} catch {
|
|||
|
|
setLocalError(t.auth.sendCodeFailed);
|
|||
|
|
} finally {
|
|||
|
|
setSendingCode(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add a cleanup `useEffect` for the countdown interval:
|
|||
|
|
```ts
|
|||
|
|
useEffect(() => {
|
|||
|
|
return () => {
|
|||
|
|
if (countdownRef.current) clearInterval(countdownRef.current);
|
|||
|
|
};
|
|||
|
|
}, []);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Update `handleSubmit` to validate code and pass it to `signUp`**
|
|||
|
|
|
|||
|
|
In the `handleSubmit` function, inside the `if (mode === 'signup')` validation block, add a check for the verification code:
|
|||
|
|
```ts
|
|||
|
|
if (!verificationCode.trim()) {
|
|||
|
|
nextFieldErrors.verificationCode = t.auth.verificationCodeRequired;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Update `fieldErrors` type to include `verificationCode`:
|
|||
|
|
```ts
|
|||
|
|
const [fieldErrors, setFieldErrors] = useState<{
|
|||
|
|
email?: string;
|
|||
|
|
password?: string;
|
|||
|
|
confirmPassword?: string;
|
|||
|
|
verificationCode?: string;
|
|||
|
|
}>({});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Update the `signUp` call to pass the code:
|
|||
|
|
```ts
|
|||
|
|
const result = mode === 'signup'
|
|||
|
|
? await signUp(normalizedEmail, password, verificationCode.trim())
|
|||
|
|
: await signIn(normalizedEmail, password);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Add Send Code button inline with the email field (signup mode only)**
|
|||
|
|
|
|||
|
|
In the JSX, find the email `<div style={s.fieldGroup}>` block. Replace it with a version that adds the send-code button when in signup mode:
|
|||
|
|
```tsx
|
|||
|
|
<div style={s.fieldGroup}>
|
|||
|
|
<label htmlFor="auth-email" style={s.label}>{t.auth.email}</label>
|
|||
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
|||
|
|
<div style={{ flex: 1 }}>
|
|||
|
|
<input
|
|||
|
|
id="auth-email"
|
|||
|
|
type="email"
|
|||
|
|
value={email}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
setEmail(e.target.value);
|
|||
|
|
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }));
|
|||
|
|
}}
|
|||
|
|
style={fieldErrors.email ? { ...s.input, ...s.inputError } : s.input}
|
|||
|
|
placeholder="your@email.com"
|
|||
|
|
required
|
|||
|
|
disabled={isBusy}
|
|||
|
|
/>
|
|||
|
|
{fieldErrors.email && <p style={s.fieldError}>{fieldErrors.email}</p>}
|
|||
|
|
{mode === 'signup' && <p style={s.fieldHint}>{t.auth.emailHint}</p>}
|
|||
|
|
</div>
|
|||
|
|
{mode === 'signup' && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleSendCode}
|
|||
|
|
disabled={isBusy || sendingCode || codeCountdown > 0}
|
|||
|
|
style={{
|
|||
|
|
flexShrink: 0,
|
|||
|
|
height: '42px',
|
|||
|
|
padding: '0 12px',
|
|||
|
|
background: codeCountdown > 0 ? '#F5DDD0' : '#C8622A',
|
|||
|
|
color: codeCountdown > 0 ? '#AA9685' : 'white',
|
|||
|
|
border: 'none',
|
|||
|
|
borderRadius: '12px',
|
|||
|
|
fontSize: '13px',
|
|||
|
|
fontWeight: 600,
|
|||
|
|
fontFamily: "'DM Sans', sans-serif",
|
|||
|
|
cursor: codeCountdown > 0 || sendingCode ? 'not-allowed' : 'pointer',
|
|||
|
|
whiteSpace: 'nowrap',
|
|||
|
|
transition: 'all 0.2s',
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{codeCountdown > 0
|
|||
|
|
? `${codeCountdown}s`
|
|||
|
|
: codeSent
|
|||
|
|
? t.auth.resendCode
|
|||
|
|
: t.auth.sendCode}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Add the verification code input field (signup mode, after confirm password)**
|
|||
|
|
|
|||
|
|
After the confirm-password `{mode === 'signup' && (...)}` block, add:
|
|||
|
|
```tsx
|
|||
|
|
{mode === 'signup' && (
|
|||
|
|
<div style={s.fieldGroup}>
|
|||
|
|
<label htmlFor="auth-code" style={s.label}>{t.auth.verificationCode}</label>
|
|||
|
|
<input
|
|||
|
|
id="auth-code"
|
|||
|
|
type="text"
|
|||
|
|
inputMode="numeric"
|
|||
|
|
maxLength={6}
|
|||
|
|
value={verificationCode}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
setVerificationCode(e.target.value.replace(/\D/g, ''));
|
|||
|
|
if (fieldErrors.verificationCode) setFieldErrors(prev => ({ ...prev, verificationCode: undefined }));
|
|||
|
|
}}
|
|||
|
|
style={fieldErrors.verificationCode ? { ...s.input, ...s.inputError } : s.input}
|
|||
|
|
placeholder={t.auth.verificationCodePlaceholder}
|
|||
|
|
disabled={isBusy}
|
|||
|
|
/>
|
|||
|
|
{fieldErrors.verificationCode
|
|||
|
|
? <p style={s.fieldError}>{fieldErrors.verificationCode}</p>
|
|||
|
|
: <p style={s.fieldHint}>{t.auth.verificationCodeHint}</p>
|
|||
|
|
}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Reset code state when switching to signin tab**
|
|||
|
|
|
|||
|
|
In the `setMode('signin')` onClick handler, add resets:
|
|||
|
|
```ts
|
|||
|
|
onClick={() => {
|
|||
|
|
setMode('signin');
|
|||
|
|
setFieldErrors({});
|
|||
|
|
setLocalError('');
|
|||
|
|
setVerificationCode('');
|
|||
|
|
setCodeSent(false);
|
|||
|
|
setCodeCountdown(0);
|
|||
|
|
if (countdownRef.current) clearInterval(countdownRef.current);
|
|||
|
|
}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Verify in browser — register flow should now require sending code first**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm run dev
|
|||
|
|
```
|
|||
|
|
Open `/app`, click register. Confirm:
|
|||
|
|
1. Email field has "Send Code" button
|
|||
|
|
2. Clicking it (with valid email) triggers send and starts countdown
|
|||
|
|
3. Verification code field appears below confirm-password
|
|||
|
|
4. Submitting without code shows validation error
|
|||
|
|
5. Login tab does not show the code field
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: Commit**
|
|||
|
|
```bash
|
|||
|
|
git add src/components/AuthModal.tsx
|
|||
|
|
git commit -m "feat: add email verification code step to registration flow"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: Final smoke test
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Run type check**
|
|||
|
|
```bash
|
|||
|
|
npm run typecheck
|
|||
|
|
```
|
|||
|
|
Expected: no errors
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run unit tests**
|
|||
|
|
```bash
|
|||
|
|
npm run test
|
|||
|
|
```
|
|||
|
|
Expected: all pass (existing tests should still pass)
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Manual end-to-end check**
|
|||
|
|
```bash
|
|||
|
|
npm run dev
|
|||
|
|
```
|
|||
|
|
Verify:
|
|||
|
|
1. Workspace navbar has no logo — only Home link on left
|
|||
|
|
2. Sidebar bottom shows no login button when logged out
|
|||
|
|
3. History toggle is off by default, shows placeholder text
|
|||
|
|
4. Toggling history on while logged out opens auth modal
|
|||
|
|
5. After 3 guest uploads, mandatory auth modal appears (no close button)
|
|||
|
|
6. Register tab: Send Code button appears, countdown works, code field required
|
|||
|
|
7. Sign-in tab: no code field visible
|
|||
|
|
8. Logged-in user: history toggle on loads history; logout resets toggle to off
|