32 Commits

Author SHA1 Message Date
6a6f7e970c feat: simplify workspace account UI and geo language defaults 2026-03-27 02:25:26 +08:00
2bcf32d678 feat: tighten workspace auth and onboarding flow 2026-03-27 02:02:12 +08:00
927fdfa97d feat: remove login button from sidebar, add history toggle 2026-03-27 01:48:36 +08:00
6ec2f5ec43 feat: remove logo from workspace navbar 2026-03-27 01:47:11 +08:00
dad9d2047d feat: add i18n keys for history toggle and email verification 2026-03-27 01:46:39 +08:00
b761f4ccbf feat: redesign showcase section with clickable demo cards
Replace static showcase layout with interactive demo grid featuring 4 real example cases (complex formulas, distorted text, handwriting, text layout). Add demo image loading via sessionStorage when users click "Try it now". New CSS grid with hover effects, overlay buttons, and responsive design. Update testimonials with new user feedback.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-27 01:22:41 +08:00
6e4df89b23 feat: redesign pricing section with beta notice and i18n pricing
- 4-card layout: Free / Monthly / Quarterly / Lifetime License
- zh shows RMB ¥, en shows $ with localized prices
- Monthly ¥19.9 (edu ¥12.9 / $1.99), Quarterly ¥49.9 (edu ¥29.9 / $7.99)
- Diagonal corner ribbons (green "限时免费") on Monthly & Quarterly
- Desktop card renamed to "永久授权 / Lifetime License" with Coming Soon ribbon
- Desktop tag badge distinguishes it as a native offline app
- Beta notice banner: all plans free during beta, no payment required
- CTA unified to "免费体验 / Try Free" linking to /app
- Cards use flex-column + align-items:stretch for consistent equal height

Made-with: Cursor
2026-03-27 00:09:00 +08:00
a6eb79f530 fix: grid lines rendering over main content — add z-index:1 to marketing main; remove playwright 2026-03-26 17:43:35 +08:00
1956842f23 fix: add useScrollReveal to BlogListPage — reveal class was hiding all content 2026-03-26 16:59:22 +08:00
99e1314bf9 refact: eliminate blog/docs content overlap
- Delete blog/copy-math-to-word (EN+ZH) — identical to docs/copy-to-word
- Rewrite blog/pdf-formula-issues as narrative troubleshooting story;
  operational steps now link out to docs/pdf-extraction
- Add "Further reading" cross-links: 4 docs → relevant blog posts
- Add "See also" cross-links: 3 blog posts → relevant docs

Docs = product reference; Blog = narrative/use cases/opinions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:52:27 +08:00
76f1bde56d feat: add 5 new blog posts (en + zh)
- how-ai-reads-math: plain-English explainer of the recognition pipeline
- student-workflow: lecture-to-LaTeX workflow for students
- pdf-formula-issues: troubleshooting guide for PDF extraction errors
- copy-math-to-word: 3 methods for getting formulas into Word, ranked
- researcher-workflow: digitizing handwritten research notes at scale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:46:31 +08:00
012748fc3d feat: refactor blog pages to match landing CSS aesthetic
- BlogListPage: removed Tailwind/lucide-react, added featured post card,
  2-col grid for remaining posts, formatDate helper, eyebrow + Lora titles
- BlogDetailPage: matches DocDetailPage (skeleton loader, not-found state,
  tags + Lora h1 + date/read-time meta, docs-prose body, CTA box)
- Added blog-specific CSS to landing.css (.blog-page, .blog-featured,
  .blog-grid, .blog-card); reuses .docs-back-link, .docs-prose, .docs-cta-box

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:40:06 +08:00
409bbf742e feat: optimize docs pages and add 4 new doc articles (en + zh)
- Rewrote DocsListPage and DocDetailPage with landing.css aesthetic
  (icon cards, skeleton loader, prose styles, CTA box)
- Added docs-specific CSS to landing.css
- Created image-to-latex, copy-to-word, ocr-accuracy, pdf-extraction
  articles in both English and Chinese
- Updated DocsSeoSection guide cards to link to real doc slugs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:15:22 +08:00
dceb775a1b feat: replace all marketing home components with reference landing design
- Extract landing.css (scoped under .marketing-page) from texpixel-landing.html
- Add Lora + JetBrains Mono fonts to index.html
- Update MarketingLayout with .marketing-page wrapper and glow blobs
- Replace MarketingNavbar with reference design (auth-aware user menu)
- Replace HeroSection with mock window + cycling LaTeX typing effect
- Replace FeaturesSection, PricingSection, Footer with reference designs
- Add ProductSuiteSection, ShowcaseSection, TestimonialsSection (carousel), DocsSeoSection
- Add useScrollReveal hook for intersection-based fade-in animations
- Update HomePage to wire all sections in correct order
- Remove obsolete HowItWorksSection and ContactSection
- Remove dead contact key from marketing.nav translations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:57:39 +08:00
d13cb64567 refact: update ui 2026-03-25 14:06:37 +08:00
276160d769 feat: update sitemap and robots.txt for new routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:28:01 +08:00
65177f12a7 feat: wire Docs and Blog pages to markdown content pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:27:25 +08:00
1ed7fad293 feat: add markdown content pipeline with build script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:26:33 +08:00
5f8d686290 feat: update AppRouter with layout routes, add Docs and Blog pages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:20:58 +08:00
e28b8294aa feat: migrate App.tsx logic to WorkspacePage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:20:01 +08:00
274342aab6 feat: add Home page with Hero, Features, HowItWorks, Pricing, Contact sections
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:18:40 +08:00
3ecf1e169c feat: add layout components (MarketingNavbar, AppNavbar, Footer, layouts)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:16:59 +08:00
f841d09010 feat: add marketing translations for en and zh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:15:25 +08:00
2f97cc6c67 feat: add SEOHead component with react-helmet-async
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:14:34 +08:00
3808417d30 feat: install react-helmet-async and gray-matter, add HelmetProvider
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:14:15 +08:00
0d4de2fcf1 feat: replace logo in main 2026-03-25 11:49:28 +08:00
3a3bbbc0fc feat: replace icon 2026-03-25 11:45:54 +08:00
e1f8dac74d feat: add icon 2026-03-25 11:14:55 +08:00
64e92c769d feat: optimize SEO based on GSC data
- Fix broken hreflang: /en/ ghost page removed, all hreflang point to canonical /
- Add canonical URL tag
- Remove emoji from page titles
- Rewrite title/description with target keywords (LaTeX OCR, math formula recognition, handwriting math)
- Add JSON-LD WebApplication structured data schema
- Update og:image to clean URL without OSS params, add og:image dimensions
- Fix favicon reference from vite.svg to favicon.png
- Add public/sitemap.xml with hreflang annotations
- Add public/robots.txt pointing to sitemap
- Update seoHelper.ts keywords for both zh/en to match search intent
- Add CLAUDE.md project documentation
2026-03-24 23:50:12 +08:00
fba4541fa5 fix: user info api repeat call 2026-03-09 21:46:26 +08:00
a797b2b0d7 fix: all call user info 2026-03-09 21:13:01 +08:00
liuyuanchuang
cd479da0eb optimize register error tip 2026-03-06 15:01:34 +08:00
102 changed files with 14535 additions and 900 deletions

View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ dist-ssr
.env
/dist
app.cloud/
public/content/

76
CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development server
npm run dev
# Build
npm run build # production
npm run build:dev # development build (VITE_ENV=development)
npm run build:prod # production build (VITE_ENV=production)
# Type checking
npm run typecheck
# Linting
npm run lint
# Unit tests (vitest)
npm run test # run once
npm run test:watch # watch mode
# E2E tests (playwright)
npm run test:e2e
```
To run a single unit test file:
```bash
npx vitest run src/components/__tests__/AuthModal.test.tsx
```
## Architecture
### Provider Tree
`main.tsx` wraps everything in: `BrowserRouter → AuthProvider → LanguageProvider → AppRouter`
### Routing
Only two routes (`src/routes/AppRouter.tsx`):
- `/``App` (main app)
- `/auth/google/callback``AuthCallbackPage` (Google OAuth callback handler)
### Auth System
- **`AuthContext.tsx`** — React context providing `signIn`, `signUp`, `beginGoogleOAuth`, `completeGoogleOAuth`, `signOut`. Uses `useReducer` with `authMachine.ts`.
- **`authMachine.ts`** — Pure reducer + state types (`AuthPhase`, `AuthState`, `AuthAction`). No side effects — all async logic lives in `AuthContext`.
- **`authService.ts`** — Calls backend API for login/register/Google OAuth/user info. Decodes JWT payload client-side to extract `UserInfo`. Handles session restore from `localStorage`.
- **`lib/api.ts`** — `http` client wrapper with `tokenManager` (stores JWT in `localStorage` under key `texpixel_token`). Adds `Authorization` header automatically. Throws `ApiError` for non-success business codes.
### Environment / API
- **`src/config/env.ts`** — Switches API base URL based on `VITE_ENV` env var:
- `development`: `https://cloud.texpixel.com:10443/doc_ai/v1`
- `production`: `https://api.texpixel.com/doc_ai/v1`
- The API uses a custom response envelope: `{ request_id, code, message, data }`. Success is `code === 200` (some endpoints also accept `0`).
### Main App Flow (`App.tsx`)
Three-panel layout: `LeftSidebar | FilePreview | ResultPanel`
Core data flow:
1. On auth, `loadFiles()` fetches paginated task history from `/task/list`
2. File upload goes: compute MD5 → get OSS pre-signed URL (`/oss/signature_url`) → PUT to OSS → create task (`/formula/recognition`) → poll every 2s for result
3. Results cached in `resultsCache` ref (keyed by `task_id`) to avoid redundant API calls
4. Guest users get 3 free uploads tracked via `localStorage` (`texpixel_guest_usage_count`)
### Internationalization
`LanguageContext.tsx` supports `en` / `zh`. Language is auto-detected via IP (`lib/ipLocation.ts`) on first visit, persisted to `localStorage` on manual switch. All UI strings come from `lib/translations.ts` via `const { t } = useLanguage()`.
### Key Type Definitions
- `src/types/api.ts` — All API request/response types, `TaskStatus` enum, `ApiErrorCode` enum, `ApiErrorMessages` map
- `src/types/index.ts` — Internal app types (`FileRecord`, `RecognitionResult`)
### Tests
- Unit tests: `src/components/__tests__/` and `src/contexts/__tests__/`, run with vitest + jsdom + `@testing-library/react`
- E2E tests: `e2e/` directory, run with Playwright
- Setup file: `src/test/setup.ts`

View File

@@ -0,0 +1,51 @@
---
title: "How AI Reads Math: Inside TexPixel's Recognition Engine"
description: A plain-English explanation of how TexPixel turns a photo of a formula into clean LaTeX code
slug: how-ai-reads-math
date: 2026-01-15
tags: [explainer, technology]
---
# How AI Reads Math: Inside TexPixel's Recognition Engine
When you upload a photo of a handwritten integral and get back clean LaTeX in under a second, it feels like magic. It's not — but the engineering behind it is genuinely interesting. Here's a plain-English explanation of how TexPixel turns pixels into math.
## Step 1: Image Preprocessing
Before any recognition happens, the image is cleaned up. This step matters more than most people realize.
TexPixel normalizes contrast, removes noise, deskews tilted images, and isolates the formula region from surrounding whitespace, printed text, or ruled lines. A formula photographed under harsh side-lighting — or scanned at a slight angle — is corrected before the model ever sees it.
This is why image quality affects accuracy so much: preprocessing can compensate for minor flaws, but severe blur or extremely low resolution (below ~72 DPI) leaves too little information to work with.
## Step 2: Symbol Detection
The preprocessed image is fed into a visual encoder — a neural network that has learned, from millions of math images, what mathematical symbols look like.
The key challenge here isn't recognizing individual symbols in isolation. It's recognizing them *in context*. The symbol `x` looks different when it's a variable, when it's a multiplication sign, and when it's written in different handwriting styles. The model learns to distinguish these from surrounding context: is there a dot nearby? What's the vertical position relative to a fraction bar?
This contextual understanding is what separates a good math OCR system from a general-purpose character recognizer.
## Step 3: Structure Parsing
Recognizing symbols is only half the problem. Math is two-dimensional in a way that ordinary text is not. A fraction has a numerator above a denominator. An integral has limits at the top and bottom. A matrix arranges expressions in rows and columns.
TexPixel's parser builds a structural tree from the detected symbols — understanding that this expression is a subscript of that symbol, and that expression lives inside a square root. This tree is then serialized into LaTeX, where the structural relationships are encoded as commands like `\frac{}{}`, `\sqrt{}`, `\sum_{}^{}`.
## Step 4: LaTeX Generation
The final step is walking the structural tree and emitting valid LaTeX. This includes choosing the right command for ambiguous cases — for example, whether a large `Σ` should be rendered as `\sum` (display math) or `\Sigma` (inline), based on context.
The output is then validated to ensure it compiles without errors before being returned.
## Why Handwriting Is Harder Than Print
Printed math (from textbooks or PDFs) has consistent, high-contrast strokes. Handwriting varies enormously — in size, slant, stroke weight, and letter formation. Two people's handwritten `7` and `1` can look nearly identical, and two people's `β` can look completely different.
TexPixel's model was trained on a large, diverse dataset of handwritten math to handle this variation. But accuracy on handwriting is always lower than on print — typically 8895% vs. 9599%. The [tips in our handwriting guide](/blog/handwriting-tips) can push that toward the upper end.
## The Whole Pipeline in One Second
Preprocessing → symbol detection → structure parsing → LaTeX generation: all of this runs in under a second. It's a well-engineered pipeline, not magic — but the speed still surprises most people the first time they try it.
[Upload a formula and see it in action →](/app)

View File

@@ -0,0 +1,73 @@
---
title: "From Whiteboard to LaTeX in 3 Seconds: A Student's Workflow"
description: How students use TexPixel to turn lecture notes and homework into clean digital documents without retyping a single formula
slug: student-workflow
date: 2026-02-01
tags: [tutorial, workflow, students]
---
# From Whiteboard to LaTeX in 3 Seconds: A Student's Workflow
If you've ever spent 20 minutes wrestling with `\underbrace`, `\overset`, or a nested fraction in LaTeX just to transcribe something your professor wrote in 10 seconds on a whiteboard — this workflow is for you.
## The Problem With Retyping
Retyping formulas by hand is slow, error-prone, and interrupts the flow of note-taking. A single misplaced brace breaks compilation. A wrong symbol — `\mu` instead of `\upsilon`, say — can change the meaning entirely. And some constructs, like large piecewise functions or multi-line aligned systems, take real LaTeX expertise to format correctly.
TexPixel removes all of this friction.
## The Workflow
### During the Lecture
Photograph each formula as it appears on the board. Don't worry about perfect framing — a quick phone shot is fine. A 150+ DPI photo taken under decent lighting gives TexPixel everything it needs.
You don't have to process anything during class. Just build up a folder of photos.
### After Class
1. Open TexPixel. Drag and drop the first photo.
2. In under a second, you get LaTeX output — paste it directly into your Overleaf document or VS Code `.tex` file.
3. Repeat for each formula.
For a typical lecture with 1015 formulas, this takes about 2 minutes. Compare that to 2030 minutes of manual retyping.
### For Homework
When working through problem sets:
1. Solve the problem on paper as you normally would.
2. Take a photo of your work.
3. Upload to TexPixel to extract the key formulas.
4. Paste into your write-up.
This is especially useful for multi-step derivations where you want to show your work digitally.
## Exporting to Word
Not using LaTeX? If your professor requires Word submissions, use TexPixel's DOCX export. It produces native Word equations — not images — so you can still edit them in Word's equation editor after exporting.
## A Real Example
Here's a typical formula from a linear algebra lecture:
$$A = U \Sigma V^T$$
Manual LaTeX: `A = U \Sigma V^T` — straightforward, but you need to know `\Sigma` and `V^T`.
With TexPixel: photograph it, get `A = U \Sigma V^T` in one second, paste. For more complex expressions — a full SVD decomposition with summation notation and indexed entries — the time savings are even more dramatic.
## Tips for Lecture Photography
- **Position yourself centrally** — formulas at the edges of the board get distorted by perspective
- **Wait for the professor to finish writing** — partial formulas confuse the parser
- **Avoid flash** — it creates glare and washes out chalk or whiteboard markers
- **Crop if needed** — if a photo contains multiple formulas, crop before uploading
## Building a Formula Library
Over a semester, you'll accumulate dozens of recognized formulas. Consider organizing them: paste each into a reference `.tex` file with a short comment. By exam time, you'll have a searchable personal formula sheet that took almost no effort to build.
**See also:** For supported file types, size limits, and copy options, see the [Image to LaTeX documentation →](/docs/image-to-latex)
[Start digitizing your notes →](/app)

View File

@@ -0,0 +1,53 @@
---
title: "I Tried to Extract Formulas from My Professor's PDF. Here's What I Learned."
description: A real-world account of what goes wrong with PDF formula extraction — and why most problems come down to one of three root causes
slug: pdf-formula-issues
date: 2026-02-15
tags: [troubleshooting, PDF]
---
# I Tried to Extract Formulas from My Professor's PDF. Here's What I Learned.
Last semester I was working through a 200-page lecture notes PDF — the kind that gets scanned from printed transparencies, emailed as a file attachment, and opens with a slightly-off angle on every page. I wanted to pull the key equations into my own notes. What followed was an education in how PDFs actually store (or don't store) mathematical content.
## The First Surprise: Not All PDFs Are the Same
I naively assumed "PDF with formulas" meant "formulas I can extract." Not true.
There are at least three fundamentally different kinds of PDFs floating around in academic circles, and they behave completely differently:
**Born-digital PDFs** (generated from LaTeX, Word, or typesetting software) contain actual vector math. Extraction from these is fast and 95%+ accurate — the formula structure is essentially already there.
**Scanned PDFs** are just photographs of printed pages packaged into a container. There's no text layer. Extraction works through image recognition, and accuracy depends entirely on scan quality. My professor's notes were this kind.
**Hybrid PDFs** have a text layer added by OCR software after scanning. Quality varies wildly — sometimes great, sometimes the "text" layer is completely wrong. These are the most unpredictable.
## The Three Root Causes of Most Failures
After a lot of trial and error, I found that failed extractions almost always come back to one of three things:
**1. Resolution.** The scan was done at 150 DPI instead of 300. At low resolution, small symbols — subscripts, primes, dots — become a few pixels wide. The model can't reliably distinguish `\prime` from a stray speck. Rescanning at 300 DPI fixed more than half my problems.
**2. Encryption.** Some PDFs are password-protected or have content restrictions that prevent any tool from reading the content stream. The PDF appears to open fine, but nothing can extract from it. Removing the password (File → Export as PDF in Preview, without the password lock) solved this.
**3. Formulas stored as vector paths.** Some PDF generators draw equations as shapes rather than encoding them as characters. To any extraction tool, these formulas are invisible — just abstract geometry. The only way around this is to render the page as an image and run visual recognition on that instead.
## What Actually Worked
For my professor's scanned notes, the workflow that worked:
1. Export each page as a 300 DPI PNG using Preview
2. Upload the PNG to TexPixel
3. Get clean LaTeX back in under a second
Not the direct-PDF workflow I was hoping for, but reliable. The image-based pipeline doesn't care whether the original was scanned or born-digital — it just sees pixels and reads the math.
## The Bigger Lesson
PDF is a presentation format, not a data format. It's optimized for how things look, not for what they mean. Mathematical notation in particular gets mangled in transit — rendered, rasterized, path-converted — in ways that destroy the underlying structure.
The most reliable signal is always the image. When in doubt, export to PNG and let visual recognition do the work.
---
For a systematic reference on PDF types, file limits, and what TexPixel can handle, see the [PDF Extraction documentation →](/docs/pdf-extraction)

View File

@@ -0,0 +1,84 @@
---
title: "Digitizing a Decade of Research Notes with TexPixel"
description: How researchers use TexPixel to convert years of handwritten math into searchable, editable LaTeX documents
slug: researcher-workflow
date: 2026-03-08
tags: [workflow, research, tutorial]
---
# Digitizing a Decade of Research Notes with TexPixel
Researchers accumulate notebooks. Derivations sketched out at conferences, margin notes on printed papers, whiteboard captures from group meetings, half-finished proofs from 3 AM. For most of history, this material was effectively unsearchable — trapped in physical form, accessible only by paging through stacks of notebooks.
TexPixel changes the equation (so to speak).
## The Scope of the Problem
A typical active researcher might accumulate 510 filled notebooks per year, each containing hundreds of equations. Digitizing this by hand — retyping each formula in LaTeX — is essentially impossible. At 3 minutes per formula and 50 formulas per notebook, one year's worth of notes would take over 400 hours to transcribe manually.
With TexPixel, each formula takes under 5 seconds from photo to LaTeX. The same year's worth of notes: under 7 hours.
## A Practical Digitization Workflow
### Step 1: Photograph the Notebooks
Use a phone with a good camera and a document scanner app (Adobe Scan, Microsoft Lens, or Apple's built-in document scanner). These apps:
- Automatically detect page edges
- Correct perspective distortion
- Apply contrast enhancement for faded ink or pencil
- Export to PDF
Scan a full notebook in 1520 minutes.
### Step 2: Identify Formula-Dense Pages
Not every page needs digitizing. Quickly flip through and flag pages with equations you'll actually need. A single key derivation or set of equations is often worth digitizing even if the surrounding text isn't.
### Step 3: Batch Process with TexPixel
For each flagged page:
1. Export the page or crop area as a PNG
2. Upload to TexPixel
3. Copy the LaTeX output into your notes
For formula-dense pages, consider cropping individual formulas rather than uploading the full page — this gives more accurate results and cleaner output.
### Step 4: Organize into a Reference Document
Create a `.tex` document (or Overleaf project) structured by topic. Paste each extracted formula with a brief comment about its context:
```latex
% Variational lower bound — from 2022 NeurIPS derivation
\mathcal{L}(\theta, \phi) = \mathbb{E}_{q_\phi(z|x)}\left[\log p_\theta(x|z)\right] - D_{KL}(q_\phi(z|x) \| p(z))
```
After a few sessions, you'll have a searchable, compilable reference document that took a fraction of the time of manual transcription.
## Working with Whiteboards
Conference room whiteboards are particularly valuable targets. A single group meeting might produce 2030 key equations that would otherwise be lost when someone erases the board.
**Best practice:** Photograph the whiteboard before it's erased (obvious) but also photograph intermediate steps — derivations that get overwritten as the discussion progresses. The intermediate steps are often where the insight lives.
For whiteboards:
- Photograph straight-on, not at an angle
- Use even lighting — a photo taken with the lights on and no flash usually works better than using flash, which creates glare on glossy boards
- Crop each distinct equation before uploading
## Working with Printed Papers
For annotated printed papers, TexPixel can extract both the printed formulas and (with somewhat lower accuracy) handwritten margin notes. Crop tightly to the region you need, and upload each formula separately from its annotations.
## Building a Long-Term Knowledge Base
The real value of digitization compounds over time. A well-organized LaTeX reference document from 5 years of notes is something you can:
- Search with `grep` or your editor's search
- Cross-reference with a citation manager
- Share with collaborators
- Build on directly when writing new papers
Start with the past year's notebooks. The 7-hour investment pays dividends for years.
**See also:** For PDF file limits, supported types, and export options, see the [PDF Extraction documentation →](/docs/pdf-extraction)
[Start digitizing your notes →](/app)

View File

@@ -0,0 +1,57 @@
---
title: "LaTeX vs MathML: Which Format Should You Use?"
description: A practical comparison of LaTeX and MathML for students and researchers
slug: latex-vs-mathml
date: 2026-03-15
tags: [guide, formats]
---
# LaTeX vs MathML: Which Format Should You Use?
TexPixel can export your recognized formulas in both LaTeX and MathML. But which one should you choose? Here's a quick guide.
## LaTeX — The Academic Standard
LaTeX is the most widely used format for typesetting math in academic papers, theses, and textbooks.
**Best for:**
- Writing papers in Overleaf, TeXmaker, or any LaTeX editor
- Pasting into Markdown documents (with KaTeX or MathJax rendering)
- Sharing formulas in forums like Stack Exchange or Reddit
**Example:**
```latex
\int_{0}^{\infty} e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
```
## MathML — The Web Standard
MathML is an XML-based format designed for displaying math in web browsers and screen readers.
**Best for:**
- Embedding formulas in HTML web pages
- Accessibility — screen readers can interpret MathML
- Word documents (DOCX uses MathML internally)
**Example:**
```xml
<math>
<msubsup><mo>&int;</mo><mn>0</mn><mi>&infin;</mi></msubsup>
<msup><mi>e</mi><mrow><mo>-</mo><msup><mi>x</mi><mn>2</mn></msup></mrow></msup>
<mi>d</mi><mi>x</mi>
</math>
```
## Quick Decision Guide
| Scenario | Use |
|----------|-----|
| Writing a paper | LaTeX |
| Homework in Google Docs / Word | MathML (via DOCX export) |
| Posting on a blog or website | LaTeX (with MathJax) |
| Accessibility-focused content | MathML |
| Sharing on social media | Image export |
## The TexPixel Advantage
You don't have to choose upfront. TexPixel recognizes your formula once and lets you export in any format — switch freely between LaTeX, MathML, Markdown, Word, and image.

View File

@@ -0,0 +1,47 @@
---
title: 5 Tips for Better Handwriting Recognition
description: Get the most accurate results from TexPixel when scanning handwritten formulas
slug: handwriting-tips
date: 2026-03-20
tags: [tutorial, tips]
---
# 5 Tips for Better Handwriting Recognition
Getting clean, accurate LaTeX from handwritten math doesn't require perfect penmanship. But a few simple habits can dramatically improve your results.
## 1. Use Dark Ink on Light Paper
High contrast is the single biggest factor in recognition accuracy. A dark pen (black or dark blue) on white or light paper gives TexPixel the clearest signal. Pencil works too, but press firmly.
## 2. Give Symbols Room to Breathe
Cramped formulas are harder for both humans and AI to read. Leave clear gaps between:
- Fraction bars and the expressions above/below them
- Subscripts and superscripts and their base symbols
- Parentheses and the terms they enclose
## 3. Be Deliberate with Similar Characters
Some characters are notoriously ambiguous in handwriting:
- **1, l, |** — make your ones with a serif or flag
- **0, O, o** — zeros should be narrower and more oval
- **x, ×** — use a clear multiplication dot (·) when you mean "times"
- **u, v** — round bottom vs. pointed bottom
## 4. Keep Your Camera Steady
If you're photographing notes with a phone:
- Hold the phone parallel to the paper (not at an angle)
- Make sure the lighting is even — no harsh shadows across the formula
- Get close enough that the formula fills most of the frame
## 5. One Formula Per Upload
TexPixel works best when each image contains a single formula or a closely related set of expressions. If you have a page full of equations, crop them individually for best results.
---
With these habits, you'll see noticeably better accuracy — often 95%+ even for complex handwritten expressions.
**See also:** For a systematic breakdown of what affects accuracy (DPI, contrast, formula complexity), see the [OCR Accuracy documentation →](/docs/ocr-accuracy)

View File

@@ -0,0 +1,26 @@
---
title: Introducing TexPixel
description: Meet TexPixel — your AI-powered formula recognition tool
slug: introducing-texpixel
date: 2026-03-25
tags: [announcement, product]
---
# Introducing TexPixel
We're excited to announce TexPixel, an AI-powered tool for converting math formulas from images into editable formats.
## Why TexPixel?
Converting handwritten or printed math formulas into digital formats has always been a tedious process. TexPixel makes it effortless — just upload an image and get LaTeX, MathML, or Markdown output in seconds.
## Key Features
- **High accuracy** — Powered by advanced AI models
- **Multiple output formats** — LaTeX, MathML, Markdown, Word
- **Easy to use** — No installation required, works in your browser
- **Free to start** — 3 free uploads per day, no credit card needed
## What's Next
We're working on exciting new features including batch processing, API access, and more. Stay tuned!

View File

@@ -0,0 +1,51 @@
---
title: "AI 如何读懂数学TexPixel 识别引擎揭秘"
description: 用通俗语言解释 TexPixel 如何将公式照片转换为干净的 LaTeX 代码
slug: how-ai-reads-math
date: 2026-01-15
tags: [技术, 原理]
---
# AI 如何读懂数学TexPixel 识别引擎揭秘
当你上传一张手写积分式的照片,不到一秒就得到了干净的 LaTeX——这感觉像魔法。其实不是但背后的工程确实很有意思。下面用通俗的语言解释 TexPixel 如何将像素转化为数学公式。
## 第一步:图像预处理
识别开始之前,图像会先被清理。这一步的重要性远超大多数人的预期。
TexPixel 会标准化对比度、去除噪点、矫正倾斜图像,并从周围的空白、印刷文字或横线中分离出公式区域。在强侧光下拍摄、或略微倾斜扫描的公式,在模型看到之前就已经被纠正了。
这就是图像质量如此影响准确率的原因:预处理可以弥补轻微的缺陷,但严重的模糊或极低分辨率(低于约 72 DPI留下的信息太少无法有效处理。
## 第二步:符号检测
预处理后的图像被输入视觉编码器——一个从数百万张数学图像中学习数学符号形态的神经网络。
这里的核心挑战不是孤立地识别单个符号,而是在**上下文中**识别它们。`x` 作为变量、作为乘号、以及以不同笔迹书写时,看起来各不相同。模型通过周围上下文来区分这些情况:附近有没有点?与分数线的垂直位置如何?
这种上下文理解,正是优秀数学 OCR 系统与通用字符识别器的本质区别。
## 第三步:结构解析
识别符号只是解决了一半的问题。数学是二维的,这是普通文字所没有的特性。分数有分子在上、分母在下;积分有上下限;矩阵将表达式排列成行和列。
TexPixel 的解析器从检测到的符号中构建结构树——理解这个表达式是那个符号的下标,那个表达式在根号内。然后将这棵树序列化为 LaTeX其中结构关系被编码为 `\frac{}{}``\sqrt{}``\sum_{}^{}` 等命令。
## 第四步LaTeX 生成
最后一步是遍历结构树并生成有效的 LaTeX。这包括处理歧义情况——例如根据上下文判断一个大写 `Σ` 应该渲染为 `\sum`(行间数学模式)还是 `\Sigma`(行内)。
输出结果在返回之前会经过验证,确保编译无误。
## 为什么手写比印刷体难
印刷数学(来自教材或 PDF笔画一致、对比度高。手写则变化极大——大小、倾斜度、笔画粗细和字母形态各异。两个人写的 `7``1` 可能几乎一样,而两个人写的 `β` 可能截然不同。
TexPixel 的模型在大量多样化的手写数学数据集上训练,以应对这种变化。但手写的准确率始终低于印刷体——通常为 8895% 对比 9599%。[手写技巧指南](/blog/handwriting-tips)中的建议可以将准确率推向上限。
## 整个流程在一秒内完成
预处理 → 符号检测 → 结构解析 → LaTeX 生成:所有这些在不到一秒内完成。这是精心设计的流水线,不是魔法——但第一次尝试时的速度仍然会让大多数人感到惊讶。
[上传公式,亲身体验 →](/app)

View File

@@ -0,0 +1,73 @@
---
title: "3 秒从白板到 LaTeX学生的高效工作流"
description: 如何用 TexPixel 把课堂笔记和作业变成干净的数字文档,无需手动输入一个公式
slug: student-workflow
date: 2026-02-01
tags: [教程, 工作流, 学生]
---
# 3 秒从白板到 LaTeX学生的高效工作流
如果你曾经为了把教授在黑板上 10 秒内写完的东西,花了 20 分钟和 `\underbrace``\overset` 或嵌套分数搏斗——这个工作流就是为你准备的。
## 手动录入的问题
手动重新输入公式既慢又容易出错,还会打断记笔记的节奏。一个错位的花括号就能导致编译失败。一个错误的符号——比如 `\mu` 写成 `\upsilon`——可能完全改变含义。某些结构,比如大型分段函数或多行对齐方程组,需要真正的 LaTeX 专业知识才能正确格式化。
TexPixel 消除了所有这些摩擦。
## 工作流程
### 上课时
每当公式出现在黑板上,拍一张照片。不用担心取景是否完美——手机随手拍就够了。在合适的光线下拍摄的 150+ DPI 照片,已经足够让 TexPixel 完成识别。
课上不需要处理任何东西,只需积累一个照片文件夹。
### 课后
1. 打开 TexPixel把第一张照片拖进去
2. 不到一秒,得到 LaTeX 输出——直接粘贴到 Overleaf 文档或 VS Code 的 `.tex` 文件中
3. 对每张公式照片重复此操作
一节课有 1015 个公式,整个过程约 2 分钟。相比手动录入的 2030 分钟,差距显著。
### 做作业时
在解题过程中:
1. 像平时一样在纸上解题
2. 拍下解题过程的照片
3. 用 TexPixel 提取关键公式
4. 粘贴到作业文档中
这对于需要展示推导过程的多步推导尤其实用。
## 导出到 Word
不用 LaTeX如果教授要求提交 Word 文档,使用 TexPixel 的 DOCX 导出功能。它生成的是原生 Word 方程式——不是图片——导出后仍然可以在 Word 的方程式编辑器中编辑。
## 实际例子
线性代数课上的一个典型公式:
$$A = U \Sigma V^T$$
手动 LaTeX`A = U \Sigma V^T`——算简单,但你需要知道 `\Sigma``V^T` 的写法。
用 TexPixel拍照一秒得到 `A = U \Sigma V^T`,粘贴。对于更复杂的表达式——带求和符号和下标的完整 SVD 分解——节省的时间更为显著。
## 课堂拍照技巧
- **站在正中间**——边角的公式会因透视产生畸变
- **等教授写完再拍**——不完整的公式会干扰解析器
- **不要用闪光灯**——会产生眩光,冲淡粉笔或白板笔
- **需要时裁剪**——如果一张照片包含多个公式,上传前先裁剪
## 建立公式库
一个学期下来,你会积累几十个识别出的公式。不妨整理一下:将每个公式粘贴到一个参考 `.tex` 文件中,加上简短注释。期末时,你将拥有一份几乎不费力气就建立起来的、可搜索的个人公式表。
**参考文档:** 关于支持的文件类型、大小限制和复制选项,请查看 [图片转 LaTeX 文档 →](/docs/image-to-latex)
[开始数字化你的笔记 →](/app)

View File

@@ -0,0 +1,53 @@
---
title: "我试着从教授的 PDF 里提取公式,结果学到了这些"
description: 一次真实的 PDF 公式提取经历——以及为什么大多数问题都归结为三个根本原因
slug: pdf-formula-issues
date: 2026-02-15
tags: [故障排查, PDF]
---
# 我试着从教授的 PDF 里提取公式,结果学到了这些
上学期我在啃一份 200 页的讲义 PDF——那种从印刷胶片扫描而来、作为附件发出来、每页都略微倾斜的类型。我想把关键方程提取到自己的笔记里。接下来发生的事让我深刻理解了 PDF 究竟是怎么存储(或者说不存储)数学内容的。
## 第一个意外:不是所有 PDF 都一样
我天真地以为"有公式的 PDF"就意味着"可以提取的公式"。并非如此。
学术圈里流传着至少三种根本不同的 PDF它们的行为完全不同
**数字原生 PDF**(由 LaTeX、Word 或排版软件生成)包含真正的矢量数学内容。从这类 PDF 提取速度快、准确率 95% 以上——公式结构本质上已经在那里了。
**扫描 PDF** 只是打印页面的照片,被包装进一个容器。没有文字层。提取依赖图像识别,准确率完全取决于扫描质量。教授的讲义就是这种。
**混合 PDF** 是扫描后由 OCR 软件添加文字层的 PDF。质量参差不齐——有时很好有时"文字层"完全是错的。这类 PDF 最难预测。
## 大多数失败的三个根本原因
经过大量尝试和失败,我发现提取失败几乎总是归结为以下三种情况之一:
**1. 分辨率。** 扫描时用了 150 DPI 而不是 300 DPI。低分辨率下小符号——下标、撇号、点——只有几个像素宽。模型无法可靠区分 `\prime` 和一个杂散的污点。提高到 300 DPI 重新扫描,解决了一半以上的问题。
**2. 加密。** 部分 PDF 有密码保护或内容限制阻止任何工具读取内容流。PDF 看起来打开正常,但没有工具能从中提取。移除密码(在 Preview 中选择"文件 → 导出为 PDF",不勾选密码锁)解决了这个问题。
**3. 公式存储为矢量路径。** 部分 PDF 生成器将方程绘制为图形而非编码为字符。对任何提取工具来说,这些公式是隐形的——只是抽象的几何图形。唯一的办法是将页面渲染为图像,然后对图像进行视觉识别。
## 最终有效的方法
对于教授的扫描讲义,有效的工作流是:
1. 用 Preview 将每页导出为 300 DPI PNG
2. 将 PNG 上传到 TexPixel
3. 不到一秒得到干净的 LaTeX
不是我期望的直接处理 PDF 的工作流,但很可靠。图像识别流程不在乎原文件是扫描的还是数字原生的——它只看像素,读取数学内容。
## 更大的启示
PDF 是展示格式,不是数据格式。它针对外观进行了优化,而不是含义。数学符号在传输过程中尤其容易被损坏——渲染、光栅化、路径转换——以破坏底层结构的方式。
最可靠的信号永远是图像。如果不确定,导出为 PNG让视觉识别来完成工作。
---
关于 PDF 类型、文件限制以及 TexPixel 支持范围的系统性参考,请查看 [PDF 公式提取文档 →](/docs/pdf-extraction)

View File

@@ -0,0 +1,84 @@
---
title: "用 TexPixel 数字化十年科研笔记"
description: 研究人员如何用 TexPixel 将多年手写数学笔记转换为可搜索、可编辑的 LaTeX 文档
slug: researcher-workflow
date: 2026-03-08
tags: [工作流, 科研, 教程]
---
# 用 TexPixel 数字化十年科研笔记
研究人员会积累笔记本。会议上草拟的推导、印刷论文上的旁注、组会白板的拍照、凌晨三点写了一半的证明。在很长一段时间里,这些材料实际上是不可搜索的——被困在物理形态中,只能翻翻一叠叠笔记本才能找到。
TexPixel 改变了这个局面。
## 问题的规模
一个活跃的研究人员每年可能积累 510 本填满的笔记本,每本包含数百个方程式。手动数字化——逐个用 LaTeX 重新输入公式——几乎是不可能完成的任务。按每个公式 3 分钟、每本 50 个公式计算,一年的笔记需要 400 多小时才能手动转录。
用 TexPixel每个公式从拍照到 LaTeX 不到 5 秒。同样一年的笔记:不到 7 小时。
## 实用数字化工作流
### 第一步:拍摄笔记本
使用摄像头好的手机和文档扫描 AppAdobe Scan、Microsoft Lens 或 Apple 内置文档扫描仪)。这些 App 能够:
- 自动检测页面边缘
- 校正透视畸变
- 对褪色墨水或铅笔字迹进行对比度增强
- 导出为 PDF
扫描一整本笔记本需要 1520 分钟。
### 第二步:确定公式密集的页面
不是每页都需要数字化。快速翻阅并标记包含你实际需要的方程式的页面。即使周围的文字不需要,一个关键推导或一组方程式往往也值得数字化。
### 第三步:用 TexPixel 批量处理
对每个标记的页面:
1. 将页面或裁剪区域导出为 PNG
2. 上传到 TexPixel
3. 将 LaTeX 输出复制到笔记中
对于公式密集的页面,建议裁剪单个公式而不是上传整页——这能获得更准确的结果和更干净的输出。
### 第四步:整理到参考文档
创建一个按主题组织的 `.tex` 文档(或 Overleaf 项目)。将每个提取的公式粘贴进去,附上简短的上下文说明:
```latex
% 变分下界——来自 2022 NeurIPS 推导
\mathcal{L}(\theta, \phi) = \mathbb{E}_{q_\phi(z|x)}\left[\log p_\theta(x|z)\right] - D_{KL}(q_\phi(z|x) \| p(z))
```
几次整理之后,你将拥有一份可搜索、可编译的参考文档,所用时间只是手动转录的零头。
## 处理白板
会议室白板是特别有价值的目标。一次组会可能产生 2030 个关键方程式,否则随着有人擦掉白板就消失了。
**最佳实践:** 在擦板前拍照(显而易见),但也要拍摄中间步骤——讨论推进过程中被覆盖的推导。中间步骤往往才是洞见所在。
白板拍摄注意事项:
- 正对白板拍摄,不要斜着拍
- 使用均匀光线——开灯不用闪光灯通常比用闪光灯更好,闪光灯会在光滑白板上产生眩光
- 上传前将各个公式分别裁剪
## 处理印刷论文
对于有批注的印刷论文TexPixel 可以提取印刷公式,也可以(以略低的准确率)识别手写旁注。紧密裁剪到需要的区域,将公式与旁注分开上传。
## 建立长期知识库
数字化的真正价值随时间复利增长。5 年笔记整理出的结构良好的 LaTeX 参考文档,你可以:
-`grep` 或编辑器搜索功能检索
- 与引用管理器交叉引用
- 与合作者共享
- 写新论文时直接在此基础上构建
从过去一年的笔记本开始。7 小时的投入,将带来多年的回报。
**参考文档:** 关于 PDF 文件限制、支持类型和导出选项,请查看 [PDF 公式提取文档 →](/docs/pdf-extraction)
[开始数字化你的笔记 →](/app)

View File

@@ -0,0 +1,48 @@
---
title: "LaTeX 和 MathML你应该用哪种格式"
description: 面向学生和研究人员的 LaTeX 与 MathML 实用对比
slug: latex-vs-mathml
date: 2026-03-15
tags: [指南, 格式]
---
# LaTeX 和 MathML你应该用哪种格式
TexPixel 可以将识别出的公式导出为 LaTeX 和 MathML 两种格式。那么你应该选择哪种?这里有一份快速指南。
## LaTeX — 学术标准
LaTeX 是学术论文、学位论文和教材中排版数学公式最广泛使用的格式。
**适用场景:**
- 在 Overleaf、TeXmaker 或任何 LaTeX 编辑器中写论文
- 粘贴到 Markdown 文档中(配合 KaTeX 或 MathJax 渲染)
- 在 Stack Exchange 或 Reddit 等论坛分享公式
**示例:**
```latex
\int_{0}^{\infty} e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
```
## MathML — Web 标准
MathML 是一种基于 XML 的格式,专为在浏览器和屏幕阅读器中显示数学内容而设计。
**适用场景:**
- 在 HTML 网页中嵌入公式
- 无障碍访问 — 屏幕阅读器可以解读 MathML
- Word 文档DOCX 内部使用 MathML
## 快速决策指南
| 场景 | 推荐格式 |
|------|---------|
| 写论文 | LaTeX |
| 在 Google Docs / Word 中做作业 | MathML通过 DOCX 导出) |
| 发布博客或网站 | LaTeX配合 MathJax |
| 注重无障碍访问 | MathML |
| 社交媒体分享 | 图片导出 |
## TexPixel 的优势
你不需要提前选择。TexPixel 识别一次公式后,可以导出为任意格式——在 LaTeX、MathML、Markdown、Word 和图片之间自由切换。

View File

@@ -0,0 +1,47 @@
---
title: 提升手写公式识别准确率的 5 个技巧
description: 使用 TexPixel 扫描手写公式时,如何获得最准确的识别结果
slug: handwriting-tips
date: 2026-03-20
tags: [教程, 技巧]
---
# 提升手写公式识别准确率的 5 个技巧
从手写数学公式获得干净、准确的 LaTeX 并不需要完美的书写。但一些简单的习惯可以显著提升识别结果。
## 1. 使用深色墨水和浅色纸张
高对比度是影响识别准确率的最大因素。在白纸或浅色纸上使用深色笔(黑色或深蓝色)能给 TexPixel 最清晰的信号。铅笔也可以,但要用力书写。
## 2. 给符号留出空间
拥挤的公式无论对人还是 AI 都更难辨认。请在以下位置留出清晰的间隔:
- 分数线与上下表达式之间
- 上标、下标与基础符号之间
- 括号与其包含的项之间
## 3. 注意易混淆字符
一些字符在手写中特别容易混淆:
- **1, l, |** — 写数字 1 时加上衬线
- **0, O, o** — 零应更窄更椭圆
- **x, ×** — 表示"乘"时使用乘号点(·)
- **u, v** — 圆底 vs 尖底
## 4. 保持拍摄稳定
如果你用手机拍摄笔记:
- 手机保持与纸面平行(不要倾斜)
- 确保光线均匀,公式上没有阴影
- 靠近拍摄,让公式占据画面的大部分
## 5. 每次上传一个公式
TexPixel 在每张图片只包含一个公式或一组紧密相关的表达式时效果最好。如果你有一整页方程,建议逐个裁剪后分别上传。
---
养成这些习惯后,你会发现识别准确率明显提升——即使是复杂的手写表达式也能达到 95% 以上。
**参考文档:** 关于影响准确率的系统性分析(分辨率、对比度、公式复杂度),请查看 [识别准确率文档 →](/docs/ocr-accuracy)

View File

@@ -0,0 +1,26 @@
---
title: TexPixel 介绍
description: 认识 TexPixel — 你的 AI 公式识别工具
slug: introducing-texpixel
date: 2026-03-25
tags: [公告, 产品]
---
# TexPixel 介绍
我们很高兴推出 TexPixel一款 AI 驱动的数学公式识别工具,可以将图片中的公式转换为可编辑的格式。
## 为什么选择 TexPixel
将手写或印刷体数学公式转换为数字格式一直是一个繁琐的过程。TexPixel 让这一切变得轻松 — 只需上传图片,即可在几秒内获得 LaTeX、MathML 或 Markdown 输出。
## 核心功能
- **高精度** — 由先进的 AI 模型驱动
- **多种输出格式** — LaTeX、MathML、Markdown、Word
- **易于使用** — 无需安装,浏览器即可使用
- **免费开始** — 每日 3 次免费上传,无需信用卡
## 接下来
我们正在开发更多令人兴奋的新功能包括批量处理、API 访问等。敬请期待!

View File

@@ -0,0 +1,68 @@
---
title: Copy to Word
description: Export recognized formulas directly into Microsoft Word as editable equations
slug: copy-to-word
date: 2026-03-25
tags: [export, Word, DOCX]
order: 4
---
# Copy to Word
TexPixel can export your recognized formulas directly into Microsoft Word as native, editable equations — not images. This means you can continue editing the formula inside Word after export.
## How to Export to Word
1. Upload your formula image and wait for recognition to complete.
2. Click the **Export** button in the result panel.
3. Select **DOCX** from the file export options.
4. Download the file and open it in Microsoft Word.
The downloaded `.docx` file contains your formula as a native Word equation (OMML format), which Word renders using its built-in equation editor.
## Why Use DOCX Export?
| Method | Editable in Word | Renders Correctly | Copy-Paste |
|---|---|---|---|
| Screenshot / image | No | Yes | No |
| LaTeX string | No (without plugin) | No | Yes |
| DOCX export | **Yes** | **Yes** | N/A |
The DOCX format is ideal when you need to:
- Submit homework or reports as Word documents
- Share formulas with colleagues who don't use LaTeX
- Continue editing the formula after export
## Inserting into an Existing Document
If you want to insert a formula into an existing Word document rather than starting fresh:
1. Open the downloaded `.docx` file in Word.
2. Select the equation and copy it (`Ctrl+C` / `Cmd+C`).
3. Paste it into your target document (`Ctrl+V` / `Cmd+V`).
Word preserves the equation formatting during paste.
## Mixed Content (Text + Formulas)
If your upload contains a mix of regular text and formulas (e.g., a textbook page), use DOCX export — it's the only format that handles mixed content correctly. LaTeX and MathML export are only available for pure-formula results.
> **Note:** For mixed-content results, LaTeX/MathML export is disabled. Use DOCX to get a properly formatted document with both text and equations.
## Compatibility
DOCX export is compatible with:
- Microsoft Word 2016 and later (Windows and Mac)
- Google Docs (equations render as images when imported)
- LibreOffice Writer (partial support)
## Tips
- After pasting into Word, double-click the equation to open the equation editor and make changes.
- If the formula looks different from expected, try re-uploading a higher-resolution image for a more accurate recognition result.
---
**Further reading:** [LaTeX vs MathML: Which Format Should You Use? →](/blog/latex-vs-mathml)
[Try exporting a formula to Word →](/app)

62
content/docs/en/faq.md Normal file
View File

@@ -0,0 +1,62 @@
---
title: FAQ
description: Frequently asked questions about TexPixel
slug: faq
date: 2026-03-25
tags: [reference]
order: 3
---
# Frequently Asked Questions
## General
### Is TexPixel free?
Yes! You can use TexPixel for free with up to 3 uploads per day. No sign-up or credit card is required. For unlimited uploads and additional features, check our Pro plan.
### Do I need to create an account?
No. You can start using TexPixel immediately as a guest. Creating an account (free) lets you sync history across devices.
### What languages are supported?
TexPixel recognizes mathematical formulas regardless of the surrounding language. The user interface is available in English and Chinese.
## Recognition
### How accurate is the recognition?
TexPixel achieves 90-98% accuracy depending on image quality and formula complexity. Clean, high-contrast images of typed formulas typically achieve the highest accuracy.
### Can it recognize handwritten formulas?
Yes. TexPixel handles both printed and handwritten formulas. For best results with handwriting, use dark ink on white paper and keep characters well-spaced.
### What about complex multi-line equations?
TexPixel can recognize multi-line equations, equation arrays, and systems of equations. Each line is captured and formatted correctly in the output.
### Does it support matrices and tables?
Yes. Matrices, determinants, and tabular expressions are supported and output as proper LaTeX `\begin{matrix}` or `\begin{pmatrix}` environments.
## Export & Integration
### Can I use the output in Overleaf?
Absolutely. Copy the LaTeX output and paste it directly into your Overleaf project. It works immediately.
### How do I use the output in Word?
Use the DOCX export option. This creates a Word file with properly formatted equations that you can edit normally in Microsoft Word or Google Docs.
## Privacy & Data
### Are my uploaded images stored?
Uploaded images are processed for recognition and temporarily cached. They are automatically deleted after processing. We do not use your images for training.
### Is my data encrypted?
Yes. All data is transmitted over HTTPS. Uploaded files and results are handled securely.

View File

@@ -0,0 +1,43 @@
---
title: Getting Started
description: Learn how to use TexPixel for formula recognition
slug: getting-started
date: 2026-03-25
tags: [tutorial, beginner]
order: 1
---
# Getting Started with TexPixel
TexPixel is an AI-powered math formula recognition tool that converts handwritten or printed formulas into LaTeX, MathML, and Markdown.
## Quick Start
1. **Upload** — Drag and drop an image or PDF containing math formulas
2. **Wait** — Our AI will process and recognize the formulas
3. **Export** — Copy the result or export in your preferred format
## Supported Formats
### Input
- Images: JPG, PNG
- Documents: PDF
### Output
- LaTeX
- MathML
- Markdown
- Word (DOCX)
- Image
## Example
Upload an image containing the quadratic formula, and TexPixel will output:
$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
## Tips
- For best results, ensure your formula images are clear and well-lit
- Handwritten formulas work best when written with dark ink on white paper
- PDF files can contain multiple pages — all formulas will be extracted

View File

@@ -0,0 +1,82 @@
---
title: Image to LaTeX
description: How to convert any formula image into clean LaTeX code with TexPixel
slug: image-to-latex
date: 2026-03-25
tags: [LaTeX, tutorial]
order: 2
---
# Image to LaTeX
TexPixel's core feature is converting formula images — from photos, scans, or screenshots — directly into LaTeX code you can paste anywhere.
## How It Works
1. **Upload your image** — Drag and drop a JPG or PNG into the upload zone, or click to browse. You can also paste from your clipboard.
2. **AI processes it** — Our model detects the formula region, runs OCR, and generates structured LaTeX in under a second.
3. **Copy the result** — Click the copy button next to the LaTeX output. Paste directly into Overleaf, VS Code, Word, or any LaTeX editor.
## Input Requirements
| Requirement | Details |
|---|---|
| File formats | JPG, PNG |
| Max file size | 10 MB |
| Recommended DPI | 150 DPI or higher |
| Background | White or light backgrounds work best |
## What Gets Recognized
TexPixel handles a wide range of mathematical content:
- **Algebra** — equations, inequalities, polynomials
- **Calculus** — derivatives, integrals, limits
- **Matrices** — 2×2 up to large arrays
- **Greek letters** — α, β, γ, Σ, Π, and more
- **Subscripts and superscripts** — `x_i^2`, `a_{n+1}`
- **Fractions** — `\frac{a}{b}`, nested fractions
- **Square roots and radicals** — `\sqrt{x}`, `\sqrt[n]{x}`
## Example
Uploading an image of the quadratic formula gives you:
```latex
x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
```
An image of an integral:
```latex
\int_0^\infty e^{-x^2}\, dx = \frac{\sqrt{\pi}}{2}
```
## Tips for Best Results
- **Use clear images** — avoid blur, shadows, or low contrast
- **Crop tightly** — the less background, the better the focus
- **Dark ink on white paper** — ideal for handwritten formulas
- **Avoid rotated images** — keep the formula horizontal
- **One formula per image** — for complex multi-part work, crop each formula separately
## Limitations
- Extremely faint or pencil-written formulas may have lower accuracy
- Hand-drawn arrows or annotation marks outside the formula may be ignored
- Very large matrices (10×10+) may have reduced accuracy
## Copy Options
After recognition, you can copy output in multiple formats:
- **LaTeX** — raw LaTeX string
- **MathML** — for web embedding
- **Markdown** — `$...$` inline or `$$...$$` block
- **Plain text** — Unicode approximation
---
**Further reading:** [From Whiteboard to LaTeX in 3 Seconds: A Student's Workflow →](/blog/student-workflow)
Ready to try it? [Upload a formula image now →](/app)

View File

@@ -0,0 +1,81 @@
---
title: OCR Accuracy
description: Understanding TexPixel recognition accuracy and how to get the best results
slug: ocr-accuracy
date: 2026-03-25
tags: [accuracy, tips]
order: 5
---
# OCR Accuracy
TexPixel achieves industry-leading accuracy on mathematical formula recognition — but accuracy isn't uniform across all input types. This guide explains what affects accuracy and how to maximize it.
## Accuracy by Formula Type
| Formula Type | Typical Accuracy |
|---|---|
| Printed formulas (textbooks, papers) | 9599% |
| Clean handwritten formulas | 8895% |
| Scanned documents (300 DPI+) | 9398% |
| Photos of whiteboards | 8292% |
| Low-resolution images (< 72 DPI) | 6080% |
These are approximate ranges. Individual results depend heavily on image quality.
## Factors That Affect Accuracy
### Image Quality
The single biggest factor. A blurry, low-resolution, or poorly lit image will always produce worse results than a clean scan.
- **Resolution** — 150 DPI or higher is recommended. 300 DPI is ideal for documents.
- **Contrast** — dark ink on a white background gives the clearest signal to the model.
- **Sharpness** — avoid motion blur or out-of-focus shots.
### Formula Complexity
Simple single-line equations are recognized with near-perfect accuracy. More complex structures may have occasional errors:
- Multi-line equation systems
- Large matrices (6×6 or larger)
- Heavily nested fractions (3+ levels deep)
- Non-standard notation or custom symbols
### Handwriting Style
Printed (typed) formulas outperform handwritten ones, but TexPixel handles handwriting well when:
- Letters are clearly formed and not connected (print style, not cursive)
- Variables are written in distinct sizes (clearly different x and × for example)
- Spacing between symbols is consistent
### What Reduces Accuracy
- **Rotated images** — formulas at an angle are harder to parse
- **Overlapping elements** — crossed-out work, annotations, or arrows near symbols
- **Pencil on paper** — low contrast; try increasing image brightness/contrast before uploading
- **Multiple formulas in one image** — crop to the specific formula you need
- **Decorative fonts** — calligraphic or stylized mathematical writing
## Improving Results
If you're getting errors, try these steps in order:
1. **Increase image resolution** — scan at 300 DPI instead of 150 DPI
2. **Improve contrast** — use a photo editor to increase brightness and contrast
3. **Crop tightly** — remove surrounding text and whitespace
4. **Straighten the image** — correct rotation before uploading
5. **Re-photograph** — better lighting, closer distance, sharper focus
## Reporting Errors
Found a formula type that TexPixel consistently gets wrong? Let us know — accuracy feedback directly improves the model over time.
Contact us at: [support@texpixel.com](mailto:support@texpixel.com)
---
**Further reading:** [5 Tips for Better Handwriting Recognition →](/blog/handwriting-tips)
[Upload a formula and test accuracy →](/app)

View File

@@ -0,0 +1,77 @@
---
title: PDF Extraction
description: Extract and convert formulas from PDF documents automatically with TexPixel
slug: pdf-extraction
date: 2026-03-25
tags: [PDF, extraction]
order: 6
---
# PDF Extraction
TexPixel can process entire PDF documents and extract every formula from every page — automatically. This is useful for textbooks, research papers, or any multi-page document with mathematical content.
## How to Extract from a PDF
1. Click the upload zone or drag and drop your PDF file.
2. TexPixel detects all pages and identifies formula regions.
3. Each recognized formula is listed in the result panel.
4. Copy individual formulas or export the entire document as DOCX.
## What Gets Extracted
TexPixel identifies formulas in PDFs regardless of whether they were:
- Typeset in LaTeX (rendered as vector math)
- Embedded as images (scanned pages)
- A mix of both
For vector PDFs (generated from LaTeX or Word), recognition accuracy is typically 95%+. For scanned/image PDFs, accuracy follows the same image quality guidelines as regular image uploads.
## Supported PDF Types
| Type | Description | Accuracy |
|---|---|---|
| Vector PDF | Created from LaTeX, Word, or typesetting tools | 9599% |
| Scanned PDF (high quality) | 300 DPI scan of printed text | 9097% |
| Scanned PDF (low quality) | < 150 DPI or poor contrast | 6080% |
| Photo PDF | Photographed pages embedded as images | 7590% |
## File Limits
- **Max file size:** 20 MB
- **Max pages:** 50 pages per upload (Pro plan: unlimited)
- **Processing time:** ~25 seconds per page
For documents exceeding these limits, split the PDF into smaller chunks before uploading.
## Exporting PDF Results
After extraction, you can export in several ways:
- **Copy individual formula** — click any recognized formula to copy its LaTeX
- **DOCX export** — download the full document with formulas as native Word equations
- **Batch copy** — copy all formulas as a list (Pro feature)
## Tips for Better PDF Results
- **Use the original PDF**, not a re-scanned copy — vector PDFs give the best results
- **Avoid password-protected PDFs** — these cannot be processed
- **Crop pages** if a PDF has wide margins with no content — smaller pages process faster
- **Split by chapter** for very large documents to stay within page limits
## Common Issues
**"No formulas found"**
The PDF may be encrypted, have formulas stored as complex vector paths, or use non-standard encoding. Try converting the page to a PNG image and uploading that instead.
**Formulas recognized but garbled**
This often happens with very low DPI scans. Try using a PDF scanner app to rescan at 300 DPI before uploading.
**Processing is slow**
Large PDFs with many pages can take 3060 seconds. This is normal. The result will appear when processing is complete.
---
**Further reading:** [I tried to extract formulas from my professor's PDF — real-world troubleshooting →](/blog/pdf-formula-issues)
[Upload a PDF and extract formulas →](/app)

View File

@@ -0,0 +1,50 @@
---
title: Supported Formats
description: Input and output formats supported by TexPixel
slug: supported-formats
date: 2026-03-25
tags: [reference]
order: 2
---
# Supported Formats
## Input Formats
TexPixel accepts the following file types for formula recognition:
### Images
- **JPEG / JPG** — Photos from cameras or phones
- **PNG** — Screenshots, scanned images, or digital drawings
### Documents
- **PDF** — Single or multi-page documents. All pages are processed and formulas are extracted from each page.
**File size limit:** 10 MB per file.
**Resolution tips:** For best results, use images at least 300 DPI. Phone photos work well when the formula fills most of the frame.
## Output Formats
After recognition, you can export results in multiple formats:
### Code Formats
- **LaTeX** — Standard math typesetting syntax, compatible with Overleaf, TeXmaker, and Markdown editors
- **MathML** — XML-based format for web pages and screen readers
### Document Formats
- **Markdown** — Ready to paste into Markdown files with inline math notation
- **Word (DOCX)** — Editable Word document with properly formatted equations
### Image Formats
- **PNG** — High-resolution rendered formula image
## Format Comparison
| Format | Best For | Editable | Web-Ready |
|--------|----------|----------|-----------|
| LaTeX | Papers, Markdown | Yes | With MathJax |
| MathML | Websites, Accessibility | Yes | Native |
| Markdown | Notes, Docs | Yes | With renderer |
| Word | Assignments | Yes | No |
| PNG | Sharing | No | Yes |

View File

@@ -0,0 +1,68 @@
---
title: 导出到 Word
description: 将识别的公式直接导出到 Microsoft Word 中作为可编辑方程
slug: copy-to-word
date: 2026-03-25
tags: [导出, Word, DOCX]
order: 4
---
# 导出到 Word
TexPixel 可以将识别的公式直接导出到 Microsoft Word 中作为原生可编辑方程——而不是图片。这意味着导出后你可以在 Word 中继续编辑公式。
## 如何导出到 Word
1. 上传公式图片并等待识别完成。
2. 点击结果面板中的**导出**按钮。
3. 从文件导出选项中选择 **DOCX**
4. 下载文件并在 Microsoft Word 中打开。
下载的 `.docx` 文件包含以原生 Word 方程OMML 格式表示的公式Word 使用内置方程编辑器渲染。
## 为什么使用 DOCX 导出?
| 方式 | Word 中可编辑 | 正确渲染 | 复制粘贴 |
|---|---|---|---|
| 截图/图片 | 否 | 是 | 否 |
| LaTeX 字符串 | 否(无插件) | 否 | 是 |
| DOCX 导出 | **是** | **是** | N/A |
DOCX 格式非常适合以下情况:
- 提交 Word 格式的作业或报告
- 与不使用 LaTeX 的同事共享公式
- 导出后继续编辑公式
## 插入到现有文档
如果你想将公式插入现有 Word 文档而不是新建文档:
1. 在 Word 中打开下载的 `.docx` 文件。
2. 选中方程并复制(`Ctrl+C` / `Cmd+C`)。
3. 粘贴到目标文档(`Ctrl+V` / `Cmd+V`)。
Word 在粘贴时保留方程格式。
## 混合内容(文字 + 公式)
如果上传内容包含普通文字和公式的混合(例如教材页面),请使用 DOCX 导出——这是唯一能正确处理混合内容的格式。LaTeX 和 MathML 导出仅适用于纯公式结果。
> **注意:** 对于混合内容结果LaTeX/MathML 导出不可用。请使用 DOCX 获取包含文字和方程的格式正确文档。
## 兼容性
DOCX 导出与以下软件兼容:
- Microsoft Word 2016 及更高版本Windows 和 Mac
- Google 文档(导入时方程渲染为图片)
- LibreOffice Writer部分支持
## 提示
- 粘贴到 Word 后,双击方程打开方程编辑器进行修改。
- 如果公式与预期不同,请尝试上传更高分辨率的图片以获得更准确的识别结果。
---
**延伸阅读:** [LaTeX vs MathML应该选哪种格式](/blog/latex-vs-mathml)
[尝试将公式导出到 Word →](/app)

62
content/docs/zh/faq.md Normal file
View File

@@ -0,0 +1,62 @@
---
title: 常见问题
description: 关于 TexPixel 的常见问题解答
slug: faq
date: 2026-03-25
tags: [参考]
order: 3
---
# 常见问题
## 基本信息
### TexPixel 是免费的吗?
是的!你可以每天免费使用 3 次上传。无需注册或绑定信用卡。如需无限上传和更多功能,请查看我们的专业版。
### 需要创建账号吗?
不需要。你可以立即以访客身份使用 TexPixel。创建免费账号后可以跨设备同步历史记录。
### 支持哪些语言?
TexPixel 可以识别任何语言环境中的数学公式。用户界面支持中文和英文。
## 识别相关
### 识别准确率如何?
根据图片质量和公式复杂度TexPixel 的准确率在 90-98% 之间。清晰、高对比度的印刷体公式通常能达到最高准确率。
### 能识别手写公式吗?
可以。TexPixel 支持印刷体和手写公式。手写公式建议使用深色墨水在白纸上书写,并保持字符间距适当。
### 支持复杂的多行方程吗?
TexPixel 可以识别多行方程、方程组和方程数组。每一行都会被准确捕获并正确格式化输出。
### 支持矩阵和表格吗?
支持。矩阵、行列式和表格表达式都能识别,并输出为正确的 LaTeX `\begin{matrix}``\begin{pmatrix}` 环境。
## 导出与集成
### 输出可以在 Overleaf 中使用吗?
当然可以。复制 LaTeX 输出,直接粘贴到你的 Overleaf 项目中即可使用。
### 如何在 Word 中使用?
使用 DOCX 导出选项。这会创建一个包含格式正确的公式的 Word 文件,你可以在 Microsoft Word 或 Google Docs 中正常编辑。
## 隐私与数据
### 上传的图片会被存储吗?
上传的图片仅用于识别处理并临时缓存,处理完成后会自动删除。我们不会使用你的图片进行训练。
### 数据是加密的吗?
是的。所有数据通过 HTTPS 传输。上传的文件和结果都经过安全处理。

View File

@@ -0,0 +1,43 @@
---
title: 快速开始
description: 了解如何使用 TexPixel 进行公式识别
slug: getting-started
date: 2026-03-25
tags: [教程, 入门]
order: 1
---
# TexPixel 快速开始
TexPixel 是一款 AI 驱动的数学公式识别工具,可以将手写或印刷体公式转换为 LaTeX、MathML 和 Markdown。
## 快速上手
1. **上传** — 拖拽包含数学公式的图片或 PDF
2. **等待** — AI 将自动处理并识别公式
3. **导出** — 复制结果或以你喜欢的格式导出
## 支持的格式
### 输入格式
- 图片JPG、PNG
- 文档PDF
### 输出格式
- LaTeX
- MathML
- Markdown
- Word (DOCX)
- 图片
## 示例
上传一张包含二次公式的图片TexPixel 将输出:
$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
## 提示
- 为获得最佳效果,请确保公式图片清晰、光线充足
- 手写公式在白纸上用深色墨水书写效果最佳
- PDF 文件可以包含多页,所有公式都会被提取

View File

@@ -0,0 +1,82 @@
---
title: 图片转 LaTeX
description: 如何使用 TexPixel 将任意公式图片转换为干净的 LaTeX 代码
slug: image-to-latex
date: 2026-03-25
tags: [LaTeX, 教程]
order: 2
---
# 图片转 LaTeX
TexPixel 的核心功能是将公式图片——来自照片、扫描件或截图——直接转换为可以粘贴到任何地方的 LaTeX 代码。
## 使用方法
1. **上传图片** — 将 JPG 或 PNG 拖拽到上传区域,或点击浏览文件。也可以直接从剪贴板粘贴。
2. **AI 处理** — 模型检测公式区域,运行 OCR在不到一秒内生成结构化 LaTeX。
3. **复制结果** — 点击 LaTeX 输出旁的复制按钮,直接粘贴到 Overleaf、VS Code、Word 或任意 LaTeX 编辑器。
## 输入要求
| 要求 | 详情 |
|---|---|
| 文件格式 | JPG、PNG |
| 最大文件大小 | 10 MB |
| 推荐分辨率 | 150 DPI 或更高 |
| 背景 | 白色或浅色背景效果最佳 |
## 支持识别的内容
TexPixel 可处理多种数学内容:
- **代数** — 方程、不等式、多项式
- **微积分** — 导数、积分、极限
- **矩阵** — 2×2 到大型数组
- **希腊字母** — α、β、γ、Σ、Π 等
- **上下标** — `x_i^2``a_{n+1}`
- **分数** — `\frac{a}{b}`、嵌套分数
- **根号** — `\sqrt{x}``\sqrt[n]{x}`
## 示例
上传二次公式图片,输出:
```latex
x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
```
上传积分图片:
```latex
\int_0^\infty e^{-x^2}\, dx = \frac{\sqrt{\pi}}{2}
```
## 获得最佳结果的技巧
- **使用清晰图片** — 避免模糊、阴影或低对比度
- **紧密裁剪** — 背景越少,焦点越准确
- **白纸深色墨水** — 手写公式的理想条件
- **避免旋转图片** — 保持公式水平
- **每张图片一个公式** — 对于复杂的多部分作业,分别裁剪每个公式
## 局限性
- 非常淡或铅笔书写的公式准确率可能较低
- 公式外的手绘箭头或注释标记可能被忽略
- 非常大的矩阵10×10 以上)可能准确率降低
## 复制选项
识别完成后,可以多种格式复制输出:
- **LaTeX** — 原始 LaTeX 字符串
- **MathML** — 用于网页嵌入
- **Markdown** — 行内 `$...$` 或块级 `$$...$$`
- **纯文本** — Unicode 近似表示
---
**延伸阅读:** [3 秒从白板到 LaTeX学生的高效工作流 →](/blog/student-workflow)
准备好了吗?[立即上传公式图片 →](/app)

View File

@@ -0,0 +1,81 @@
---
title: 识别准确率
description: 了解 TexPixel 识别准确率及如何获得最佳效果
slug: ocr-accuracy
date: 2026-03-25
tags: [准确率, 技巧]
order: 5
---
# 识别准确率
TexPixel 在数学公式识别方面达到行业领先的准确率——但准确率在不同输入类型之间并不统一。本指南解释影响准确率的因素以及如何最大化识别效果。
## 按公式类型的准确率
| 公式类型 | 典型准确率 |
|---|---|
| 印刷体公式(教材、论文) | 9599% |
| 清晰手写公式 | 8895% |
| 扫描文档300 DPI+ | 9398% |
| 白板照片 | 8292% |
| 低分辨率图片(< 72 DPI | 6080% |
这些是大致范围,实际结果在很大程度上取决于图片质量。
## 影响准确率的因素
### 图片质量
这是最重要的单一因素。模糊、低分辨率或光线不佳的图片效果始终不如清晰扫描件。
- **分辨率** — 建议 150 DPI 或更高,文档理想为 300 DPI
- **对比度** — 白色背景上的深色墨水为模型提供最清晰的信号
- **清晰度** — 避免运动模糊或对焦不准
### 公式复杂度
简单的单行方程识别准确率接近完美。更复杂的结构可能偶有错误:
- 多行方程组
- 大矩阵6×6 或更大)
- 深度嵌套分数3 层以上)
- 非标准符号或自定义符号
### 手写风格
印刷体打字公式优于手写体但当以下条件满足时TexPixel 能很好地处理手写:
- 字母清晰成形且不连笔(印刷体,而非草书)
- 变量写成明显不同的大小(例如 x 和 × 清晰区分)
- 符号间距一致
### 降低准确率的因素
- **旋转图片** — 倾斜的公式更难解析
- **重叠元素** — 划掉的内容、注释或符号附近的箭头
- **纸上铅笔** — 对比度低;上传前可尝试增加图片亮度/对比度
- **一张图片多个公式** — 裁剪到你需要的具体公式
- **装饰字体** — 花体或风格化数学书写
## 提高识别效果
如果识别出错,按以下顺序尝试:
1. **提高图片分辨率** — 用 300 DPI 扫描代替 150 DPI
2. **改善对比度** — 使用图片编辑器提高亮度和对比度
3. **紧密裁剪** — 去除周围文字和空白
4. **矫正图片** — 上传前纠正旋转
5. **重新拍摄** — 更好的光线、更近的距离、更清晰的对焦
## 反馈错误
发现 TexPixel 持续识别错误的公式类型?请告知我们——准确率反馈直接改进模型。
联系我们:[support@texpixel.com](mailto:support@texpixel.com)
---
**延伸阅读:** [提高手写公式识别准确率的 5 个技巧 →](/blog/handwriting-tips)
[上传公式测试识别准确率 →](/app)

View File

@@ -0,0 +1,77 @@
---
title: PDF 公式提取
description: 使用 TexPixel 自动从 PDF 文档中提取并转换公式
slug: pdf-extraction
date: 2026-03-25
tags: [PDF, 提取]
order: 6
---
# PDF 公式提取
TexPixel 可以处理完整的 PDF 文档,自动从每一页提取所有公式。这对教材、研究论文或任何包含数学内容的多页文档非常有用。
## 如何从 PDF 提取
1. 点击上传区域或将 PDF 文件拖拽到其中。
2. TexPixel 检测所有页面并识别公式区域。
3. 每个识别的公式列在结果面板中。
4. 复制单个公式或将整个文档导出为 DOCX。
## 提取内容
无论 PDF 中的公式是如何生成的TexPixel 都能识别:
- 用 LaTeX 排版(渲染为矢量数学)
- 嵌入为图片(扫描页面)
- 两种混合
对于矢量 PDF由 LaTeX 或 Word 生成),识别准确率通常为 95% 以上。对于扫描/图片 PDF准确率遵循与普通图片上传相同的图片质量准则。
## 支持的 PDF 类型
| 类型 | 描述 | 准确率 |
|---|---|---|
| 矢量 PDF | 由 LaTeX、Word 或排版工具创建 | 9599% |
| 扫描 PDF高质量 | 印刷文字的 300 DPI 扫描 | 9097% |
| 扫描 PDF低质量 | < 150 DPI 或对比度差 | 6080% |
| 照片 PDF | 嵌入为图片的拍照页面 | 7590% |
## 文件限制
- **最大文件大小:** 20 MB
- **最大页数:** 每次上传 50 页(专业版:无限制)
- **处理时间:** 每页约 25 秒
对于超出限制的文档,上传前将 PDF 分割成较小的部分。
## 导出 PDF 识别结果
提取后,可以多种方式导出:
- **复制单个公式** — 点击任意识别的公式复制其 LaTeX
- **DOCX 导出** — 下载包含原生 Word 方程的完整文档
- **批量复制** — 将所有公式复制为列表(专业版功能)
## 提高 PDF 识别效果的技巧
- **使用原始 PDF**,而非重新扫描的副本——矢量 PDF 效果最佳
- **避免密码保护的 PDF**——这类文件无法处理
- 如果 PDF 有很宽的空白边距,**裁剪页面**——较小的页面处理更快
- 对于非常大的文档,**按章节分割**以保持在页数限制内
## 常见问题
**"未找到公式"**
PDF 可能已加密,公式可能以复杂矢量路径存储,或使用了非标准编码。尝试将页面转换为 PNG 图片后再上传。
**公式已识别但内容乱码**
这通常发生在非常低 DPI 的扫描件上。尝试在上传前使用 PDF 扫描应用以 300 DPI 重新扫描。
**处理速度慢**
包含多页的大型 PDF 可能需要 3060 秒。这是正常的,处理完成后结果会显示。
---
**延伸阅读:** [我试着从教授的 PDF 里提取公式——真实排障经历 →](/blog/pdf-formula-issues)
[上传 PDF 提取公式 →](/app)

View File

@@ -0,0 +1,50 @@
---
title: 支持的格式
description: TexPixel 支持的输入和输出格式
slug: supported-formats
date: 2026-03-25
tags: [参考]
order: 2
---
# 支持的格式
## 输入格式
TexPixel 接受以下文件类型进行公式识别:
### 图片
- **JPEG / JPG** — 相机或手机拍摄的照片
- **PNG** — 截图、扫描图片或数字绘图
### 文档
- **PDF** — 单页或多页文档。所有页面都会被处理,每一页中的公式都会被提取。
**文件大小限制:** 每个文件最大 10 MB。
**分辨率建议:** 为获得最佳效果,建议使用至少 300 DPI 的图片。手机拍照时让公式占据画面的大部分即可。
## 输出格式
识别完成后,你可以将结果导出为多种格式:
### 代码格式
- **LaTeX** — 标准数学排版语法,兼容 Overleaf、TeXmaker 和 Markdown 编辑器
- **MathML** — 基于 XML 的格式,适用于网页和屏幕阅读器
### 文档格式
- **Markdown** — 可直接粘贴到 Markdown 文件中,包含内联数学符号
- **Word (DOCX)** — 可编辑的 Word 文档,公式格式完整
### 图片格式
- **PNG** — 高分辨率渲染的公式图片
## 格式对比
| 格式 | 最适合 | 可编辑 | 网页就绪 |
|------|--------|--------|---------|
| LaTeX | 论文、Markdown | 是 | 需 MathJax |
| MathML | 网站、无障碍 | 是 | 原生支持 |
| Markdown | 笔记、文档 | 是 | 需渲染器 |
| Word | 作业 | 是 | 否 |
| PNG | 分享 | 否 | 是 |

View File

@@ -0,0 +1,974 @@
# Website Restructure 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:** Restructure the single-page OCR app into a multi-section marketing website with dedicated workspace, docs, and blog pages.
**Architecture:** SPA with react-router-dom layout routes. MarketingLayout (Navbar + Footer) wraps Home/Docs/Blog. AppLayout wraps the OCR workspace at `/app`. Markdown content compiled at build time via a Vite plugin. SEO handled by react-helmet-async + vite-plugin-prerender.
**Tech Stack:** React 18, react-router-dom, Tailwind CSS, react-helmet-async, vite-plugin-prerender, remark/rehype (existing), gray-matter
---
## File Structure
```
src/
├── components/
│ ├── home/
│ │ ├── HeroSection.tsx — Hero with OCR demo + CTA
│ │ ├── FeaturesSection.tsx — Feature cards grid
│ │ ├── HowItWorksSection.tsx — 3-step flow
│ │ ├── PricingSection.tsx — Price cards
│ │ └── ContactSection.tsx — Contact info + form
│ ├── layout/
│ │ ├── MarketingNavbar.tsx — Full site nav
│ │ ├── AppNavbar.tsx — Workspace nav (from Navbar.tsx)
│ │ ├── Footer.tsx — Site footer
│ │ ├── MarketingLayout.tsx — MarketingNavbar + Outlet + Footer
│ │ └── AppLayout.tsx — AppNavbar + Outlet
│ └── seo/
│ └── SEOHead.tsx — react-helmet-async wrapper
├── pages/
│ ├── HomePage.tsx
│ ├── WorkspacePage.tsx — migrated from App.tsx
│ ├── DocsListPage.tsx
│ ├── DocDetailPage.tsx
│ ├── BlogListPage.tsx
│ └── BlogDetailPage.tsx
├── lib/
│ └── content.ts — Load markdown manifests
├── routes/
│ └── AppRouter.tsx — Updated with layout routes
content/
├── docs/
│ ├── en/getting-started.md
│ └── zh/getting-started.md
└── blog/
├── en/2026-03-25-introducing-texpixel.md
└── zh/2026-03-25-introducing-texpixel.md
scripts/
└── build-content.ts — Compile markdown to JSON
```
---
### Task 1: Install dependencies and setup
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Install react-helmet-async and gray-matter**
```bash
npm install react-helmet-async gray-matter
```
- [ ] **Step 2: Wrap app with HelmetProvider**
In `src/main.tsx`, add `HelmetProvider` wrapping:
```tsx
import { HelmetProvider } from 'react-helmet-async';
// Wrap inside StrictMode:
<HelmetProvider>
<BrowserRouter>
<AuthProvider>
<LanguageProvider>
<AppRouter />
</LanguageProvider>
</AuthProvider>
</BrowserRouter>
</HelmetProvider>
```
- [ ] **Step 3: Commit**
```bash
git add package.json package-lock.json src/main.tsx
git commit -m "feat: install react-helmet-async and gray-matter, add HelmetProvider"
```
---
### Task 2: Create SEOHead component
**Files:**
- Create: `src/components/seo/SEOHead.tsx`
- [ ] **Step 1: Create SEOHead component**
```tsx
import { Helmet } from 'react-helmet-async';
interface SEOHeadProps {
title: string;
description: string;
path: string;
type?: 'website' | 'article';
image?: string;
publishedTime?: string;
noindex?: boolean;
}
const BASE_URL = 'https://texpixel.com';
export default function SEOHead({
title,
description,
path,
type = 'website',
image = 'https://cdn.texpixel.com/public/og-cover.png',
publishedTime,
noindex = false,
}: SEOHeadProps) {
const url = `${BASE_URL}${path}`;
const fullTitle = path === '/' ? title : `${title} | TexPixel`;
return (
<Helmet>
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
{/* Open Graph */}
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:type" content={type} />
<meta property="og:image" content={image} />
<meta property="og:site_name" content="TexPixel" />
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
</Helmet>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/components/seo/SEOHead.tsx
git commit -m "feat: add SEOHead component with react-helmet-async"
```
---
### Task 3: Create layout components
**Files:**
- Create: `src/components/layout/MarketingNavbar.tsx`
- Create: `src/components/layout/AppNavbar.tsx`
- Create: `src/components/layout/Footer.tsx`
- Create: `src/components/layout/MarketingLayout.tsx`
- Create: `src/components/layout/AppLayout.tsx`
- [ ] **Step 1: Create MarketingNavbar**
Full-width navbar with logo, nav links (Home, Docs, Blog), anchor links (Pricing, Contact on home page), language switcher, and CTA button to `/app`. Use `useLocation` to show anchor links only when on `/`. Responsive with mobile hamburger menu.
```tsx
// src/components/layout/MarketingNavbar.tsx
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Languages, ChevronDown, Check, Menu, X } from 'lucide-react';
import { useLanguage } from '../../contexts/LanguageContext';
export default function MarketingNavbar() {
const { language, setLanguage, t } = useLanguage();
const location = useLocation();
const [showLangMenu, setShowLangMenu] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isHome = location.pathname === '/';
const navLinks = [
{ to: '/', label: t.marketing?.nav?.home ?? 'Home' },
{ to: '/docs', label: t.marketing?.nav?.docs ?? 'Docs' },
{ to: '/blog', label: t.marketing?.nav?.blog ?? 'Blog' },
];
const anchorLinks = isHome
? [
{ href: '#pricing', label: t.marketing?.nav?.pricing ?? 'Pricing' },
{ href: '#contact', label: t.marketing?.nav?.contact ?? 'Contact' },
]
: [];
return (
<nav className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0 z-[60] relative">
{/* Logo */}
<Link to="/" className="flex items-center gap-2">
<img src="/texpixel-app-icon.svg" alt="TexPixel" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900 tracking-tight">TexPixel</span>
</Link>
{/* Desktop Nav */}
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className={`text-sm font-medium transition-colors ${
location.pathname === link.to ? 'text-blue-600' : 'text-gray-700 hover:text-gray-900'
}`}
>
{link.label}
</Link>
))}
{anchorLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
>
{link.label}
</a>
))}
</div>
{/* Right actions */}
<div className="flex items-center gap-3">
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setShowLangMenu(!showLangMenu)}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
>
<Languages size={18} />
<span className="hidden sm:inline">{language === 'en' ? 'EN' : '中文'}</span>
<ChevronDown size={14} className={`transition-transform duration-200 ${showLangMenu ? 'rotate-180' : ''}`} />
</button>
{showLangMenu && (
<div className="absolute right-0 top-full mt-2 w-32 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50">
{(['en', 'zh'] as const).map((lang) => (
<button
key={lang}
onClick={() => { setLanguage(lang); setShowLangMenu(false); }}
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === lang ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
>
{lang === 'en' ? 'English' : '简体中文'}
{language === lang && <Check size={14} />}
</button>
))}
</div>
)}
</div>
{/* CTA */}
<Link
to="/app"
className="hidden sm:inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
{t.marketing?.nav?.launchApp ?? 'Launch App'}
</Link>
{/* Mobile menu toggle */}
<button className="md:hidden p-2" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="absolute top-16 left-0 right-0 bg-white border-b border-gray-200 shadow-lg md:hidden z-50 py-4 px-6 flex flex-col gap-3">
{navLinks.map((link) => (
<Link key={link.to} to={link.to} className="text-sm font-medium text-gray-700 py-2" onClick={() => setMobileMenuOpen(false)}>
{link.label}
</Link>
))}
{anchorLinks.map((link) => (
<a key={link.href} href={link.href} className="text-sm font-medium text-gray-700 py-2" onClick={() => setMobileMenuOpen(false)}>
{link.label}
</a>
))}
<Link to="/app" className="text-sm font-medium text-blue-600 py-2" onClick={() => setMobileMenuOpen(false)}>
{t.marketing?.nav?.launchApp ?? 'Launch App'}
</Link>
</div>
)}
</nav>
);
}
```
- [ ] **Step 2: Create AppNavbar**
Simplified version of current `Navbar.tsx` for the workspace. Keep language switcher, reward, contact, guide, help — remove marketing nav links. Add a "Back to Home" link.
Copy current `src/components/Navbar.tsx` content into `src/components/layout/AppNavbar.tsx`. Add a `Link` to `/` (home icon or "TexPixel" logo links to `/`). Keep all existing functionality (reward modal, contact dropdown, language switcher, guide button).
- [ ] **Step 3: Create Footer**
```tsx
// src/components/layout/Footer.tsx
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
export default function Footer() {
const { t } = useLanguage();
return (
<footer className="bg-gray-900 text-gray-400 py-12 px-6">
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Brand */}
<div>
<div className="flex items-center gap-2 mb-4">
<img src="/texpixel-app-icon.svg" alt="TexPixel" className="w-6 h-6" />
<span className="text-white font-bold">TexPixel</span>
</div>
<p className="text-sm">{t.marketing?.footer?.tagline ?? 'AI-powered math formula recognition'}</p>
</div>
{/* Product */}
<div>
<h4 className="text-white font-semibold mb-3 text-sm">{t.marketing?.footer?.product ?? 'Product'}</h4>
<div className="flex flex-col gap-2 text-sm">
<Link to="/app" className="hover:text-white transition-colors">{t.marketing?.nav?.launchApp ?? 'Launch App'}</Link>
<a href="/#pricing" className="hover:text-white transition-colors">{t.marketing?.nav?.pricing ?? 'Pricing'}</a>
</div>
</div>
{/* Resources */}
<div>
<h4 className="text-white font-semibold mb-3 text-sm">{t.marketing?.footer?.resources ?? 'Resources'}</h4>
<div className="flex flex-col gap-2 text-sm">
<Link to="/docs" className="hover:text-white transition-colors">{t.marketing?.nav?.docs ?? 'Docs'}</Link>
<Link to="/blog" className="hover:text-white transition-colors">{t.marketing?.nav?.blog ?? 'Blog'}</Link>
</div>
</div>
{/* Contact */}
<div>
<h4 className="text-white font-semibold mb-3 text-sm">{t.marketing?.footer?.contactTitle ?? 'Contact'}</h4>
<div className="flex flex-col gap-2 text-sm">
<a href="mailto:yogecoder@gmail.com" className="hover:text-white transition-colors">yogecoder@gmail.com</a>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto mt-8 pt-8 border-t border-gray-800 text-sm text-center">
&copy; {new Date().getFullYear()} TexPixel. All rights reserved.
</div>
</footer>
);
}
```
- [ ] **Step 4: Create MarketingLayout and AppLayout**
```tsx
// src/components/layout/MarketingLayout.tsx
import { Outlet } from 'react-router-dom';
import MarketingNavbar from './MarketingNavbar';
import Footer from './Footer';
export default function MarketingLayout() {
return (
<div className="min-h-screen flex flex-col bg-white">
<MarketingNavbar />
<main className="flex-1">
<Outlet />
</main>
<Footer />
</div>
);
}
```
```tsx
// src/components/layout/AppLayout.tsx
import { Outlet } from 'react-router-dom';
import AppNavbar from './AppNavbar';
export default function AppLayout() {
return (
<div className="h-screen flex flex-col bg-gray-50 font-sans text-gray-900 overflow-hidden">
<AppNavbar />
<div className="flex-1 flex overflow-hidden">
<Outlet />
</div>
</div>
);
}
```
- [ ] **Step 5: Commit**
```bash
git add src/components/layout/
git commit -m "feat: add layout components (MarketingNavbar, AppNavbar, Footer, layouts)"
```
---
### Task 4: Add marketing translations
**Files:**
- Modify: `src/lib/translations.ts`
- [ ] **Step 1: Add marketing section to translations**
Add `marketing` key to both `en` and `zh` objects in `translations.ts`:
```typescript
marketing: {
nav: {
home: 'Home', // zh: '首页'
docs: 'Docs', // zh: '文档'
blog: 'Blog', // zh: '博客'
pricing: 'Pricing', // zh: '价格'
contact: 'Contact', // zh: '联系我们'
launchApp: 'Launch App', // zh: '启动应用'
},
hero: {
title: 'Convert Math Formulas to LaTeX in Seconds',
// zh: '数学公式秒级转换为 LaTeX'
subtitle: 'AI-powered OCR for handwritten and printed mathematical formulas. Get LaTeX, MathML, and Markdown output instantly.',
// zh: 'AI 驱动的手写和印刷体数学公式识别,即时输出 LaTeX、MathML 和 Markdown。'
cta: 'Try It Free', // zh: '免费试用'
ctaSecondary: 'Learn More', // zh: '了解更多'
},
features: {
title: 'Features', // zh: '功能特性'
subtitle: 'Everything you need for formula recognition',
// zh: '公式识别所需的一切'
items: [
{ title: 'Handwriting Recognition', description: 'Accurately recognize handwritten math formulas from photos or scans' },
{ title: 'Multi-Format Output', description: 'Export to LaTeX, MathML, Markdown, Word, and more' },
{ title: 'PDF Support', description: 'Upload PDF documents and extract formulas automatically' },
{ title: 'Batch Processing', description: 'Process multiple files at once for maximum efficiency' },
{ title: 'High Accuracy', description: 'Powered by advanced AI models for industry-leading accuracy' },
{ title: 'Free to Start', description: 'Get started with free uploads, no credit card required' },
],
// zh versions of items array
},
howItWorks: {
title: 'How It Works', // zh: '使用流程'
steps: [
{ title: 'Upload', description: 'Upload an image or PDF containing math formulas' },
{ title: 'Recognize', description: 'Our AI analyzes and recognizes the formulas' },
{ title: 'Export', description: 'Copy or export results in your preferred format' },
],
},
pricing: {
title: 'Pricing', // zh: '价格方案'
subtitle: 'Choose the plan that fits your needs',
// zh: '选择适合您的方案'
plans: [
{ name: 'Free', price: '$0', period: '/month', features: ['3 uploads/day', 'LaTeX & Markdown output', 'Community support'], cta: 'Get Started' },
{ name: 'Pro', price: '$9.9', period: '/month', features: ['Unlimited uploads', 'All export formats', 'Priority processing', 'API access'], cta: 'Coming Soon', popular: true },
{ name: 'Enterprise', price: 'Custom', period: '', features: ['Custom volume', 'Dedicated support', 'SLA guarantee', 'On-premise option'], cta: 'Contact Us' },
],
},
contact: {
title: 'Contact Us', // zh: '联系我们'
subtitle: 'Get in touch with our team',
// zh: '与我们的团队取得联系'
nameLabel: 'Name', // zh: '姓名'
emailLabel: 'Email', // zh: '邮箱'
messageLabel: 'Message', // zh: '留言'
send: 'Send Message', // zh: '发送消息'
sending: 'Sending...', // zh: '发送中...'
sent: 'Message sent!', // zh: '消息已发送!'
qqGroup: 'QQ Group', // zh: 'QQ 群'
},
footer: {
tagline: 'AI-powered math formula recognition',
// zh: 'AI 驱动的数学公式识别'
product: 'Product', // zh: '产品'
resources: 'Resources', // zh: '资源'
contactTitle: 'Contact', // zh: '联系方式'
},
},
```
- [ ] **Step 2: Commit**
```bash
git add src/lib/translations.ts
git commit -m "feat: add marketing translations for en and zh"
```
---
### Task 5: Create Home page sections
**Files:**
- Create: `src/components/home/HeroSection.tsx`
- Create: `src/components/home/FeaturesSection.tsx`
- Create: `src/components/home/HowItWorksSection.tsx`
- Create: `src/components/home/PricingSection.tsx`
- Create: `src/components/home/ContactSection.tsx`
- Create: `src/pages/HomePage.tsx`
- [ ] **Step 1: Create HeroSection**
Hero with product tagline, a mini drag-and-drop demo area (visual only, clicking it navigates to `/app`), and CTA buttons. Use Tailwind for gradient backgrounds and animations.
Key elements:
- Large heading from `t.marketing.hero.title`
- Subtitle from `t.marketing.hero.subtitle`
- Primary CTA button → links to `/app`
- Secondary CTA button → scrolls to `#features`
- A decorative mock preview showing a formula being converted (static image or CSS illustration)
- [ ] **Step 2: Create FeaturesSection**
6-card grid from `t.marketing.features.items`. Each card has an icon (from lucide-react), title, and description. Use icons: `PenTool`, `FileOutput`, `FileText`, `Layers`, `Zap`, `Gift`.
- [ ] **Step 3: Create HowItWorksSection**
3-step horizontal flow with numbered circles, title, description. Steps from `t.marketing.howItWorks.steps`. Use icons: `Upload`, `Cpu`, `Download`.
- [ ] **Step 4: Create PricingSection**
3-column card layout from `t.marketing.pricing.plans`. Middle card (Pro) has `popular: true` → highlighted border/badge. CTA buttons: Free → link to `/app`, Pro → disabled "Coming Soon", Enterprise → link to `#contact`.
Section has `id="pricing"` for anchor navigation.
- [ ] **Step 5: Create ContactSection**
Two-column layout. Left: contact info (email, QQ group). Right: form with name, email, message fields + submit button. Form initially just shows a success toast on submit (no backend). `id="contact"` for anchor nav.
- [ ] **Step 6: Create HomePage**
```tsx
// src/pages/HomePage.tsx
import SEOHead from '../components/seo/SEOHead';
import HeroSection from '../components/home/HeroSection';
import FeaturesSection from '../components/home/FeaturesSection';
import HowItWorksSection from '../components/home/HowItWorksSection';
import PricingSection from '../components/home/PricingSection';
import ContactSection from '../components/home/ContactSection';
import { useLanguage } from '../contexts/LanguageContext';
export default function HomePage() {
const { t } = useLanguage();
return (
<>
<SEOHead
title="TexPixel - AI Math Formula Recognition | LaTeX, MathML OCR Tool"
description={t.marketing.hero.subtitle}
path="/"
/>
<HeroSection />
<FeaturesSection />
<HowItWorksSection />
<PricingSection />
<ContactSection />
</>
);
}
```
- [ ] **Step 7: Commit**
```bash
git add src/components/home/ src/pages/HomePage.tsx
git commit -m "feat: add Home page with Hero, Features, HowItWorks, Pricing, Contact sections"
```
---
### Task 6: Migrate App.tsx to WorkspacePage
**Files:**
- Create: `src/pages/WorkspacePage.tsx`
- Modify: `src/App.tsx` (will become thin redirect or removed)
- [ ] **Step 1: Create WorkspacePage**
Move all logic from `App.tsx` into `WorkspacePage.tsx`. Remove the outer `<div className="h-screen flex flex-col ...">` and `<Navbar />` wrappers since `AppLayout` provides those. Keep the inner flex container with LeftSidebar, FilePreview, ResultPanel, modals, and loading overlay.
The component should render:
```tsx
<>
<SEOHead title="Workspace" description="..." path="/app" noindex />
{/* Left Sidebar */}
<div ref={sidebarRef} ...>
<LeftSidebar ... />
{/* Resize Handle */}
</div>
{/* Middle: FilePreview */}
<div className="flex-1 ..."><FilePreview ... /></div>
{/* Right: ResultPanel */}
<div className="flex-1 ..."><ResultPanel ... /></div>
{/* Modals */}
{showUploadModal && <UploadModal ... />}
{showAuthModal && <AuthModal ... />}
<UserGuide ... />
{loading && <div>...</div>}
</>
```
Note: `AppLayout` already provides `<div className="h-screen flex flex-col ...">` and `<AppNavbar />` and `<div className="flex-1 flex overflow-hidden">`, so WorkspacePage renders directly inside that flex container.
- [ ] **Step 2: Update App.tsx**
Replace `App.tsx` with a simple redirect to maintain backward compatibility if anything imports it:
```tsx
import { Navigate } from 'react-router-dom';
export default function App() {
return <Navigate to="/" replace />;
}
```
- [ ] **Step 3: Verify build**
```bash
npm run typecheck
```
- [ ] **Step 4: Commit**
```bash
git add src/pages/WorkspacePage.tsx src/App.tsx
git commit -m "feat: migrate App.tsx logic to WorkspacePage"
```
---
### Task 7: Update AppRouter with layout routes
**Files:**
- Modify: `src/routes/AppRouter.tsx`
- [ ] **Step 1: Update AppRouter**
```tsx
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import MarketingLayout from '../components/layout/MarketingLayout';
import AppLayout from '../components/layout/AppLayout';
import AuthCallbackPage from '../pages/AuthCallbackPage';
const HomePage = lazy(() => import('../pages/HomePage'));
const WorkspacePage = lazy(() => import('../pages/WorkspacePage'));
const DocsListPage = lazy(() => import('../pages/DocsListPage'));
const DocDetailPage = lazy(() => import('../pages/DocDetailPage'));
const BlogListPage = lazy(() => import('../pages/BlogListPage'));
const BlogDetailPage = lazy(() => import('../pages/BlogDetailPage'));
function LoadingFallback() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
export default function AppRouter() {
return (
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route element={<MarketingLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/docs" element={<DocsListPage />} />
<Route path="/docs/:slug" element={<DocDetailPage />} />
<Route path="/blog" element={<BlogListPage />} />
<Route path="/blog/:slug" element={<BlogDetailPage />} />
</Route>
<Route element={<AppLayout />}>
<Route path="/app" element={<WorkspacePage />} />
</Route>
<Route path="/auth/google/callback" element={<AuthCallbackPage />} />
</Routes>
</Suspense>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/routes/AppRouter.tsx
git commit -m "feat: update AppRouter with layout routes and lazy loading"
```
---
### Task 8: Create placeholder Docs and Blog pages
**Files:**
- Create: `src/pages/DocsListPage.tsx`
- Create: `src/pages/DocDetailPage.tsx`
- Create: `src/pages/BlogListPage.tsx`
- Create: `src/pages/BlogDetailPage.tsx`
- [ ] **Step 1: Create DocsListPage**
List page showing available docs. For now, hardcode a few placeholder entries. Each entry links to `/docs/:slug`. Include SEOHead.
```tsx
import { Link } from 'react-router-dom';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
const docs = [
{ slug: 'getting-started', title: 'Getting Started', titleZh: '快速开始', description: 'Learn how to use TexPixel', descriptionZh: '了解如何使用 TexPixel' },
];
export default function DocsListPage() {
const { language } = useLanguage();
return (
<>
<SEOHead title="Documentation" description="TexPixel documentation and guides" path="/docs" />
<div className="max-w-4xl mx-auto py-16 px-6">
<h1 className="text-3xl font-bold text-gray-900 mb-8">{language === 'en' ? 'Documentation' : '文档'}</h1>
<div className="space-y-4">
{docs.map((doc) => (
<Link key={doc.slug} to={`/docs/${doc.slug}`} className="block p-6 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<h2 className="text-lg font-semibold text-gray-900">{language === 'en' ? doc.title : doc.titleZh}</h2>
<p className="text-gray-600 mt-1 text-sm">{language === 'en' ? doc.description : doc.descriptionZh}</p>
</Link>
))}
</div>
</div>
</>
);
}
```
- [ ] **Step 2: Create DocDetailPage**
Placeholder that reads `:slug` from params and shows a coming-soon message.
```tsx
import { useParams } from 'react-router-dom';
import SEOHead from '../components/seo/SEOHead';
export default function DocDetailPage() {
const { slug } = useParams<{ slug: string }>();
return (
<>
<SEOHead title={slug ?? 'Doc'} description={`Documentation: ${slug}`} path={`/docs/${slug}`} />
<div className="max-w-4xl mx-auto py-16 px-6">
<h1 className="text-3xl font-bold text-gray-900 mb-4">{slug}</h1>
<p className="text-gray-600">Content coming soon.</p>
</div>
</>
);
}
```
- [ ] **Step 3: Create BlogListPage and BlogDetailPage**
Same pattern as docs. BlogListPage shows placeholder blog entries with date and title. BlogDetailPage reads `:slug` param.
- [ ] **Step 4: Commit**
```bash
git add src/pages/DocsListPage.tsx src/pages/DocDetailPage.tsx src/pages/BlogListPage.tsx src/pages/BlogDetailPage.tsx
git commit -m "feat: add placeholder Docs and Blog pages"
```
---
### Task 9: Build content pipeline (Markdown → JSON)
**Files:**
- Create: `content/docs/en/getting-started.md`
- Create: `content/docs/zh/getting-started.md`
- Create: `content/blog/en/2026-03-25-introducing-texpixel.md`
- Create: `content/blog/zh/2026-03-25-introducing-texpixel.md`
- Create: `scripts/build-content.ts`
- Create: `src/lib/content.ts`
- Modify: `package.json` (add build:content script)
- [ ] **Step 1: Create sample markdown files**
Each file has frontmatter (title, description, slug, date, tags, order) and body content.
- [ ] **Step 2: Create build-content script**
Node script that:
1. Scans `content/docs/{en,zh}/` and `content/blog/{en,zh}/`
2. Parses frontmatter with `gray-matter`
3. Compiles markdown body with `remark` + `rehype` → HTML string
4. Outputs `public/content/docs-manifest.json` and `public/content/blog-manifest.json`
5. Outputs individual `public/content/docs/{lang}/{slug}.json` and `public/content/blog/{lang}/{slug}.json`
Manifest format:
```json
{
"en": [{ "slug": "getting-started", "title": "...", "description": "...", "date": "...", "tags": [], "order": 1 }],
"zh": [...]
}
```
Individual file format:
```json
{ "meta": { ... frontmatter ... }, "html": "<p>compiled html</p>" }
```
- [ ] **Step 3: Create content loader utility**
```tsx
// src/lib/content.ts
import type { Language } from './translations';
export interface ContentMeta {
slug: string;
title: string;
description: string;
date: string;
tags: string[];
order?: number;
cover?: string;
}
export interface ContentManifest {
en: ContentMeta[];
zh: ContentMeta[];
}
export interface ContentItem {
meta: ContentMeta;
html: string;
}
const BASE = '/content';
export async function loadManifest(type: 'docs' | 'blog'): Promise<ContentManifest> {
const res = await fetch(`${BASE}/${type}-manifest.json`);
return res.json();
}
export async function loadContent(type: 'docs' | 'blog', lang: Language, slug: string): Promise<ContentItem> {
const res = await fetch(`${BASE}/${type}/${lang}/${slug}.json`);
return res.json();
}
```
- [ ] **Step 4: Add npm script**
In `package.json`, add:
```json
"build:content": "npx tsx scripts/build-content.ts",
"build": "npm run build:content && vite build"
```
- [ ] **Step 5: Commit**
```bash
git add content/ scripts/build-content.ts src/lib/content.ts package.json
git commit -m "feat: add markdown content pipeline with build script"
```
---
### Task 10: Wire Docs/Blog pages to content pipeline
**Files:**
- Modify: `src/pages/DocsListPage.tsx`
- Modify: `src/pages/DocDetailPage.tsx`
- Modify: `src/pages/BlogListPage.tsx`
- Modify: `src/pages/BlogDetailPage.tsx`
- [ ] **Step 1: Update DocsListPage to load from manifest**
Use `useEffect` + `loadManifest('docs')` to fetch doc list. Render based on current language.
- [ ] **Step 2: Update DocDetailPage to load content**
Use `useEffect` + `loadContent('docs', language, slug)` to fetch and render HTML. Use `dangerouslySetInnerHTML` for the compiled HTML (safe since we control the source markdown). Apply Tailwind typography classes (`prose`).
- [ ] **Step 3: Update Blog pages similarly**
Same pattern. BlogListPage shows date + cover image. BlogDetailPage renders article with `type="article"` in SEOHead.
- [ ] **Step 4: Run build:content and test**
```bash
npm run build:content && npm run dev
```
Visit `/docs`, `/docs/getting-started`, `/blog`, `/blog/introducing-texpixel`.
- [ ] **Step 5: Commit**
```bash
git add src/pages/
git commit -m "feat: wire Docs and Blog pages to markdown content pipeline"
```
---
### Task 11: Update sitemap and SEO infrastructure
**Files:**
- Modify: `public/sitemap.xml`
- Modify: `public/robots.txt`
- Modify: `index.html`
- [ ] **Step 1: Update sitemap.xml**
Add all new routes: `/`, `/app`, `/docs`, `/docs/getting-started`, `/blog`, `/blog/introducing-texpixel`. Set appropriate `changefreq` and `priority`.
- [ ] **Step 2: Update robots.txt**
Add `Disallow: /app` to prevent indexing of the workspace.
- [ ] **Step 3: Clean up index.html**
Since SEOHead now manages per-page meta tags via react-helmet-async, simplify `index.html` to only keep the base defaults. Remove the inline language detection script (LanguageContext handles this).
- [ ] **Step 4: Commit**
```bash
git add public/sitemap.xml public/robots.txt index.html
git commit -m "feat: update sitemap, robots.txt, and index.html for new routes"
```
---
### Task 12: Final verification
- [ ] **Step 1: Type check**
```bash
npm run typecheck
```
- [ ] **Step 2: Lint**
```bash
npm run lint
```
- [ ] **Step 3: Build**
```bash
npm run build
```
- [ ] **Step 4: Test all routes in dev**
```bash
npm run dev
```
Visit: `/`, `/app`, `/docs`, `/docs/getting-started`, `/blog`, `/blog/introducing-texpixel`
Verify:
- Home page shows all sections, anchor links work
- `/app` workspace functions as before
- Docs/Blog pages load content
- Language switching works across all pages
- Mobile responsive nav works
- [ ] **Step 5: Commit any fixes and final commit**
```bash
git add -A
git commit -m "feat: complete website restructure with marketing pages, docs, and blog"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,856 @@
# 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 3851) 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

View File

@@ -0,0 +1,75 @@
# Website Restructure Design
## Overview
Restructure the single-page OCR app into a multi-section website with Home, Docs, Blog, Pricing, Contact modules while maintaining the core OCR tool functionality in a dedicated workspace page.
## Routes
| Route | Page | SEO | Layout |
|-------|------|-----|--------|
| `/` | HomePage (Hero + Features + HowItWorks + Pricing + Contact) | Prerender | MarketingLayout |
| `/app` | WorkspacePage (current 3-panel OCR layout) | noindex | AppLayout |
| `/docs` | DocsListPage | Prerender | MarketingLayout |
| `/docs/:slug` | DocDetailPage | Prerender | MarketingLayout |
| `/blog` | BlogListPage | Prerender | MarketingLayout |
| `/blog/:slug` | BlogDetailPage | Prerender | MarketingLayout |
| `/auth/google/callback` | AuthCallbackPage | noindex | None |
## Home Page Sections
1. **Hero** — Product tagline + mini upload demo + CTA to `/app`
2. **Features** — Feature cards grid (formula recognition, multi-format export, etc.)
3. **How it works** — 3-step process
4. **Pricing** — Static price cards (Free / Pro / Enterprise), payment integration later
5. **Contact** — Contact info display + form submission
## SEO Strategy
- **Prerendering**: `vite-plugin-prerender` for static HTML generation of marketing pages
- **Meta tags**: `react-helmet-async` for per-page title, description, OG/Twitter cards
- **Structured data**: JSON-LD (SoftwareApplication, Article, TechArticle)
- **Sitemap**: Auto-generated at build time from routes + markdown content
- **i18n SEO**: `hreflang` tags for zh/en
- `/app` route gets `noindex`
## Markdown Content System
```
content/
├── docs/{en,zh}/*.md
└── blog/{en,zh}/*.md
```
- Frontmatter: title, description, slug, date, tags, order, cover
- Build script scans content/, parses frontmatter, generates manifest JSON
- Body compiled via remark + rehype (reusing existing KaTeX pipeline)
## Component Architecture
### Layouts
- `MarketingLayout` — MarketingNavbar + Outlet + Footer
- `AppLayout` — AppNavbar + Outlet
### New Pages
- `src/pages/HomePage.tsx`
- `src/pages/WorkspacePage.tsx` (migrated from App.tsx)
- `src/pages/DocsListPage.tsx`
- `src/pages/DocDetailPage.tsx`
- `src/pages/BlogListPage.tsx`
- `src/pages/BlogDetailPage.tsx`
### Home Sections
- `src/components/home/HeroSection.tsx`
- `src/components/home/FeaturesSection.tsx`
- `src/components/home/HowItWorksSection.tsx`
- `src/components/home/PricingSection.tsx`
- `src/components/home/ContactSection.tsx`
### Shared Layout Components
- `src/components/layout/MarketingNavbar.tsx`
- `src/components/layout/AppNavbar.tsx` (evolved from current Navbar.tsx)
- `src/components/layout/Footer.tsx`
## Migration
- `App.tsx` core logic → `WorkspacePage.tsx`
- `Navbar.tsx` → split into `MarketingNavbar` + `AppNavbar`
- All existing components (LeftSidebar, FilePreview, ResultPanel, etc.) unchanged, referenced by WorkspacePage
## Lazy Loading
All page components use `React.lazy()` + `Suspense` to keep initial bundle small.

View File

@@ -0,0 +1,129 @@
# Landing Page Refactor — Design Spec
**Date:** 2026-03-26
**Status:** Approved
## Goal
Replace all existing marketing home components with content and styles from `texpixel-landing.html`. The UI/UX must exactly match the reference file. The home page is a marketing/broadcast page; all CTAs navigate to `/app`.
---
## CSS Strategy
- Extract the full `<style>` block (lines 101593) from `texpixel-landing.html` into `src/styles/landing.css`.
- **Scope all rules** under a `.marketing-page` wrapper class to prevent bleed into the `/app` workspace.
- Body-level rules (`body { background }`, `body::before` grid overlay) are converted to `.marketing-page` and `.marketing-page::before` respectively.
- `:root` CSS variable declarations are kept as-is since landing variables use different names (`--primary`, `--bg`, etc.) from existing workspace variables (`--color-primary`, `--color-bg`). No conflict — they coexist.
- **Do NOT import in `main.tsx`** — import directly in `MarketingLayout.tsx` via `import '../styles/landing.css'` so it only applies to marketing routes.
- `MarketingLayout.tsx` wrapper div gets `className="marketing-page"`.
- `index.css` Tailwind layer remains untouched.
- Add Google Fonts to `index.html` `<head>`: Lora (serif, weights 400/600/700) and JetBrains Mono (monospace, weights 400/500). DM Sans already present.
---
## Component Mapping
| Reference section | Target file | Action |
|---|---|---|
| `<nav>` | `src/components/layout/MarketingNavbar.tsx` | Replace |
| `.hero` | `src/components/home/HeroSection.tsx` | Replace |
| `.product-suite` | `src/components/home/ProductSuiteSection.tsx` | New |
| `.core-features` | `src/components/home/FeaturesSection.tsx` | Replace |
| `.showcase` | `src/components/home/ShowcaseSection.tsx` | New |
| `.user-love` | `src/components/home/TestimonialsSection.tsx` | New |
| `.pricing` | `src/components/home/PricingSection.tsx` | Replace |
| `.docs-seo` | `src/components/home/DocsSeoSection.tsx` | New |
| `<footer>` | `src/components/layout/Footer.tsx` | Replace |
### Delete (no reference equivalent)
- `src/components/home/HowItWorksSection.tsx`
- `src/components/home/ContactSection.tsx`
---
## MarketingLayout.tsx
- Wrap outlet in `<div className="marketing-page">` — applies scoped landing CSS
- Render three `.glow-blob` divs (`.glow-blob-1`, `.glow-blob-2`, `.glow-blob-3`) as direct children of the `.marketing-page` wrapper — these are `position: fixed` ambient background elements visible across all marketing pages.
---
## HomePage.tsx
Update to render sections in order:
```
HeroSection
<div className="section-divider" />
ProductSuiteSection
FeaturesSection
ShowcaseSection
<div className="section-divider" />
TestimonialsSection
<div className="section-divider" />
PricingSection
<div className="section-divider" />
DocsSeoSection
```
Section dividers are plain `<div className="section-divider" />` JSX inlined in `HomePage.tsx` — no abstraction needed.
---
## Navbar (MarketingNavbar.tsx)
- Sticky, height 72px, backdrop blur on scroll (existing scroll state logic kept)
- Logo: SVG icon (lines symbol) + "TexPixel" text — **remove `font-display` Tailwind class** from logo `<span>`, replace with `style={{ fontFamily: "'Lora', serif" }}` to avoid Plus Jakarta Sans conflict
- Nav links: Home `/`, Docs `/docs`, Blog `/blog`, Pricing `#pricing` (anchor on home only), **no Contact link** — also remove the existing `anchorLinks` `#contact` entry
- Right side:
- Lang switch button (existing `useLanguage` toggle)
- User avatar/menu using `const { user, signOut } = useAuth()` — show avatar dropdown when `user !== null`; show "登录/Login" CTA button when `user === null`
- Avatar dropdown items: "启动应用" → `/app`, then logout (calls `signOut()`). **No profile settings link** (route does not exist — omitted)
- "Try Free" CTA button → `/app`
- i18n: `useLanguage` for all labels
- Remove unused `t.marketing.nav.contact` references from this component
---
## JS Behaviors → React hooks/useEffect
### Scroll Reveal Hook
- **Create `src/hooks/` directory** (does not exist yet)
- Create `src/hooks/useScrollReveal.ts` — sets up a single `IntersectionObserver` targeting all `.reveal` elements, adds `.visible` class on intersection
- Called once in `HomePage.tsx` via `useScrollReveal()`
### Nav Active on Scroll
- `useEffect` in `MarketingNavbar.tsx` — watches `window.scroll`, adds `.active` class to nav link matching current section `id`
### Testimonial Carousel (TestimonialsSection.tsx)
- React state: `currentPage` (0-indexed), 6 cards, 3 visible, 4 pages
- `useEffect` auto-advances every 5s, resets on manual navigation
- Prev/Next buttons + rendered dots
- Window resize recalcs slide offset
### Typing Effect (HeroSection.tsx)
- `useRef` on `.output-code` element
- `useEffect` cycles through 3 LaTeX strings every 3500ms via `innerHTML` + cursor span
---
## CTA Links
- "Try TexPixel", "Try Free", "Get Started" (Free/Monthly/Quarterly plans) → `<Link to="/app">`
- "Buy Desktop" → `<Link to="/app">` (placeholder, no separate purchase flow)
- Doc card links → `/docs`
- Footer blog link → `/blog`
- Pricing anchor `#pricing``<a href="#pricing">`
---
## Content / i18n
- All text from the reference HTML is hardcoded in components (Chinese/English bilingual where reference already has it)
- Existing `useLanguage` / `t` translations are used where keys already exist
- New section text is **hardcoded** (not added to `translations.ts`) — the reference HTML content is the source of truth; full i18n for new sections is out of scope for this refactor
---
## Cleanup
- Remove `contact` key from `marketing.nav` in `src/lib/translations.ts` (both `en` and `zh` blocks) — becomes dead code after ContactSection and `#contact` link are removed.
---
## What Does NOT Change
- `src/App.tsx`, routing (`AppRouter.tsx`), auth system, workspace (`WorkspacePage`)
- `index.css` Tailwind layer
- Docs/Blog pages
- `SEOHead` component usage in `HomePage.tsx`
- `tailwind.config.js`

View File

@@ -3,56 +3,98 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/texpixel-app-icon.svg" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Multi-language Support -->
<!-- Fonts: Plus Jakarta Sans (headings) + DM Sans (body) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&family=Lora:ital,wght@0,400;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<!-- hreflang: same URL serves both languages (SPA), point both to canonical -->
<link rel="canonical" href="https://texpixel.com/" />
<link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/" />
<link rel="alternate" hreflang="en" href="https://texpixel.com/en" />
<link rel="alternate" hreflang="en" href="https://texpixel.com/" />
<link rel="alternate" hreflang="x-default" href="https://texpixel.com/" />
<!-- Dynamic Title (will be updated by app) -->
<title>⚡️ TexPixel - 公式识别工具 | Formula Recognition Tool</title>
<!-- Title -->
<title>TexPixel - AI Math Formula Recognition | LaTeX, MathML OCR Tool</title>
<!-- SEO Meta Tags - Chinese (Default) -->
<meta name="description" content="在线公式识别工具支持印刷体和手写体数学公式识别快速准确地将图片中的数学公式转换为可编辑文本。Online formula recognition tool supporting printed and handwritten math formulas." />
<meta name="keywords"
content="公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,formula recognition,math formula,handwriting recognition,latex,mathml,markdown,texpixel,TexPixel,混合文字识别,document recognition" />
<!-- SEO Meta Tags -->
<meta name="description" content="Free AI-powered math formula recognition tool. Convert handwritten or printed math formulas in images to LaTeX, MathML, and Markdown instantly. Supports PDF and image files." />
<meta name="keywords" content="math formula recognition,LaTeX OCR,handwriting math recognition,formula to latex,math OCR,MathML converter,handwritten equation recognition,公式识别,数学公式OCR,手写公式识别,LaTeX转换,texpixel" />
<meta name="author" content="TexPixel Team" />
<meta name="robots" content="index, follow" />
<!-- Open Graph Meta Tags - Bilingual -->
<meta property="og:title" content="TexPixel - 公式识别工具 | Formula Recognition Tool" />
<meta property="og:description" content="在线公式识别工具支持印刷体和手写体数学公式识别。Online formula recognition tool supporting printed and handwritten math formulas." />
<!-- Open Graph -->
<meta property="og:title" content="TexPixel - AI Math Formula Recognition Tool" />
<meta property="og:description" content="Free AI-powered tool to convert handwritten or printed math formulas to LaTeX, MathML, and Markdown. Upload an image or PDF and get results instantly." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://texpixel.com/" />
<meta property="og:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
<meta property="og:locale" content="zh_CN" />
<meta property="og:locale:alternate" content="en_US" />
<meta property="og:image" content="https://cdn.texpixel.com/public/og-cover.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="en_US" />
<meta property="og:locale:alternate" content="zh_CN" />
<meta property="og:site_name" content="TexPixel" />
<!-- Twitter Card Meta Tags - Bilingual -->
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="TexPixel - Formula Recognition Tool | 公式识别工具" />
<meta name="twitter:description" content="Online formula recognition tool supporting printed and handwritten math formulas. 支持印刷体和手写体数学公式识别。" />
<meta name="twitter:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
<meta name="twitter:title" content="TexPixel - AI Math Formula Recognition Tool" />
<meta name="twitter:description" content="Convert handwritten or printed math formulas to LaTeX, MathML, and Markdown for free. Upload image or PDF — results in seconds." />
<meta name="twitter:image" content="https://cdn.texpixel.com/public/og-cover.png" />
<meta name="twitter:site" content="@TexPixel" />
<!-- Baidu Verification -->
<meta name="baidu-site-verification" content="codeva-8zU93DeGgH" />
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "TexPixel",
"url": "https://texpixel.com/",
"description": "AI-powered math formula recognition tool that converts handwritten or printed formulas in images to LaTeX, MathML, and Markdown.",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "Web",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"featureList": [
"Handwritten math formula recognition",
"Printed formula OCR",
"LaTeX output",
"MathML output",
"Markdown output",
"PDF support",
"Image support"
],
"inLanguage": ["en", "zh-CN"],
"publisher": {
"@type": "Organization",
"name": "TexPixel",
"url": "https://texpixel.com/"
}
}
</script>
<!-- Language Detection Script -->
<script>
// Update HTML lang attribute based on user preference or browser language
(function() {
const savedLang = localStorage.getItem('language');
const browserLang = navigator.language.toLowerCase();
const isZh = savedLang === 'zh' || (!savedLang && browserLang.startsWith('zh'));
document.documentElement.lang = isZh ? 'zh-CN' : 'en';
// Update page title based on language
if (!isZh) {
document.title = '⚡️ TexPixel - Formula Recognition Tool';
if (isZh) {
document.title = 'TexPixel - AI 数学公式识别工具 | LaTeX、MathML OCR';
document.querySelector('meta[name="description"]').setAttribute('content',
'免费 AI 数学公式识别工具,支持手写和印刷体公式识别,一键将图片或 PDF 中的数学公式转换为 LaTeX、MathML 和 Markdown 格式。');
}
})();
</script>

1074
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,11 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "VITE_ENV=development vite build",
"build:prod": "VITE_ENV=production vite build",
"dev": "npm run build:content && vite",
"build": "npm run build:content && vite build",
"build:content": "npx tsx scripts/build-content.ts",
"build:dev": "npm run build:content && VITE_ENV=development vite build",
"build:prod": "npm run build:content && VITE_ENV=production vite build",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
@@ -20,12 +21,14 @@
"@types/spark-md5": "^3.0.5",
"browser-image-compression": "^2.0.2",
"clsx": "^2.1.1",
"gray-matter": "^4.0.3",
"html-to-image": "^1.11.13",
"katex": "^0.16.27",
"lucide-react": "^0.344.0",
"mathml2omml": "^0.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^3.0.0",
"react-hot-toast": "^2.6.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
@@ -37,7 +40,6 @@
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@playwright/test": "^1.58.2",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -52,9 +54,15 @@
"globals": "^15.9.0",
"jsdom": "^28.1.0",
"postcss": "^8.4.35",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"tailwindcss": "^3.4.1",
"tsx": "^4.21.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"unified": "^11.0.5",
"vite": "^5.4.2",
"vitest": "^4.0.18"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

68
public/favicon.svg Normal file
View File

@@ -0,0 +1,68 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Light mode: dark lines -->
<linearGradient id="l1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="20%" stop-color="#111111" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.9"/>
</linearGradient>
<linearGradient id="l2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="24%" stop-color="#111111" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.6"/>
</linearGradient>
<linearGradient id="l3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="22%" stop-color="#111111" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.35"/>
</linearGradient>
<linearGradient id="l4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="28%" stop-color="#111111" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.18"/>
</linearGradient>
<!-- Dark mode: white lines -->
<linearGradient id="d1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="20%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.95"/>
</linearGradient>
<linearGradient id="d2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="24%" stop-color="#ffffff" stop-opacity="0.65"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.65"/>
</linearGradient>
<linearGradient id="d3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="22%" stop-color="#ffffff" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.4"/>
</linearGradient>
<linearGradient id="d4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="28%" stop-color="#ffffff" stop-opacity="0.22"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.22"/>
</linearGradient>
</defs>
<style>
.light-icon { display: block; }
.dark-icon { display: none; }
@media (prefers-color-scheme: dark) {
.light-icon { display: none; }
.dark-icon { display: block; }
}
</style>
<!-- Light mode group -->
<g class="light-icon">
<rect x="3" y="7" width="24" height="2.2" rx="1.1" fill="url(#l1)"/>
<rect x="4" y="12.5" width="18" height="2.2" rx="1.1" fill="url(#l2)"/>
<rect x="3" y="18" width="20" height="2.2" rx="1.1" fill="url(#l3)"/>
<rect x="5" y="23.5" width="14" height="2.2" rx="1.1" fill="url(#l4)"/>
</g>
<!-- Dark mode group -->
<g class="dark-icon">
<rect x="3" y="7" width="24" height="2.2" rx="1.1" fill="url(#d1)"/>
<rect x="4" y="12.5" width="18" height="2.2" rx="1.1" fill="url(#d2)"/>
<rect x="3" y="18" width="20" height="2.2" rx="1.1" fill="url(#d3)"/>
<rect x="5" y="23.5" width="14" height="2.2" rx="1.1" fill="url(#d4)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
public/fomula_demo/mix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

5
public/robots.txt Normal file
View File

@@ -0,0 +1,5 @@
User-agent: *
Allow: /
Disallow: /app
Sitemap: https://texpixel.com/sitemap.xml

17
public/site.webmanifest Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "TexPixel - AI Math Formula Recognition",
"short_name": "TexPixel",
"description": "Free AI-powered math formula recognition tool. Convert handwritten or printed math formulas to LaTeX, MathML, and Markdown.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#6366f1",
"icons": [
{
"src": "/texpixel-app-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

37
public/sitemap.xml Normal file
View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://texpixel.com/</loc>
<lastmod>2026-03-25</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/"/>
<xhtml:link rel="alternate" hreflang="en" href="https://texpixel.com/"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://texpixel.com/"/>
</url>
<url>
<loc>https://texpixel.com/docs</loc>
<lastmod>2026-03-25</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://texpixel.com/docs/getting-started</loc>
<lastmod>2026-03-25</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://texpixel.com/blog</loc>
<lastmod>2026-03-25</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://texpixel.com/blog/introducing-texpixel</loc>
<lastmod>2026-03-25</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>

View File

@@ -0,0 +1,29 @@
<svg width="1024" height="1024" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="line1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="25%" stop-color="#ffffff" stop-opacity="0.92"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.92"/>
</linearGradient>
<linearGradient id="line2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="30%" stop-color="#ffffff" stop-opacity="0.62"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.62"/>
</linearGradient>
<linearGradient id="line3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="28%" stop-color="#ffffff" stop-opacity="0.38"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.38"/>
</linearGradient>
<linearGradient id="line4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="35%" stop-color="#ffffff" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#000000"/>
<rect x="24" y="42" width="74" height="3" rx="1.5" fill="url(#line1)"/>
<rect x="26" y="56" width="56" height="3" rx="1.5" fill="url(#line2)"/>
<rect x="24" y="70" width="64" height="3" rx="1.5" fill="url(#line3)"/>
<rect x="28" y="84" width="44" height="3" rx="1.5" fill="url(#line4)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,29 @@
<svg width="1024" height="1024" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="line1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="25%" stop-color="#ffffff" stop-opacity="0.92"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.92"/>
</linearGradient>
<linearGradient id="line2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="30%" stop-color="#ffffff" stop-opacity="0.62"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.62"/>
</linearGradient>
<linearGradient id="line3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="28%" stop-color="#ffffff" stop-opacity="0.38"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.38"/>
</linearGradient>
<linearGradient id="line4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="35%" stop-color="#ffffff" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#000000"/>
<rect x="24" y="42" width="74" height="3" rx="1.5" fill="url(#line1)"/>
<rect x="26" y="56" width="56" height="3" rx="1.5" fill="url(#line2)"/>
<rect x="24" y="70" width="64" height="3" rx="1.5" fill="url(#line3)"/>
<rect x="28" y="84" width="44" height="3" rx="1.5" fill="url(#line4)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

116
scripts/build-content.ts Normal file
View File

@@ -0,0 +1,116 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeKatex from 'rehype-katex';
import rehypeStringify from 'rehype-stringify';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONTENT_DIR = path.resolve(__dirname, '../content');
const OUTPUT_DIR = path.resolve(__dirname, '../public/content');
interface ContentMeta {
slug: string;
title: string;
description: string;
date: string;
tags: string[];
order?: number;
cover?: string;
}
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype)
.use(rehypeKatex)
.use(rehypeStringify);
async function compileMarkdown(content: string): Promise<string> {
const result = await processor.process(content);
return String(result);
}
async function processContentType(type: 'docs' | 'blog') {
const typeDir = path.join(CONTENT_DIR, type);
const manifest: Record<string, ContentMeta[]> = {};
for (const lang of ['en', 'zh']) {
const langDir = path.join(typeDir, lang);
if (!fs.existsSync(langDir)) {
manifest[lang] = [];
continue;
}
const files = fs.readdirSync(langDir).filter(f => f.endsWith('.md'));
const entries: ContentMeta[] = [];
for (const file of files) {
const filePath = path.join(langDir, file);
const raw = fs.readFileSync(filePath, 'utf-8');
const { data, content } = matter(raw);
const meta: ContentMeta = {
slug: data.slug || file.replace(/\.md$/, ''),
title: data.title || '',
description: data.description || '',
date: data.date ? String(data.date) : '',
tags: data.tags || [],
order: data.order,
cover: data.cover,
};
entries.push(meta);
// Compile and write individual content file
const html = await compileMarkdown(content);
const outputPath = path.join(OUTPUT_DIR, type, lang, `${meta.slug}.json`);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify({ meta, html }, null, 2));
console.log(` ${lang}/${meta.slug}`);
}
// Sort: docs by order, blog by date descending
if (type === 'docs') {
entries.sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
} else {
entries.sort((a, b) => b.date.localeCompare(a.date));
}
manifest[lang] = entries;
}
// Write manifest
const manifestPath = path.join(OUTPUT_DIR, `${type}-manifest.json`);
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log(` -> ${type}-manifest.json`);
}
async function main() {
console.log('Building content...');
// Clean output
if (fs.existsSync(OUTPUT_DIR)) {
fs.rmSync(OUTPUT_DIR, { recursive: true });
}
await processContentType('docs');
await processContentType('blog');
console.log('Content build complete!');
}
main().catch(err => {
console.error('Content build failed:', err);
process.exit(1);
});

10
skills-lock.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
}
}
}

View File

@@ -1,588 +1,5 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useAuth } from './contexts/AuthContext';
import { useLanguage } from './contexts/LanguageContext';
import { uploadService } from './lib/uploadService';
import { FileRecord, RecognitionResult } from './types';
import { TaskStatus, TaskHistoryItem } from './types/api';
import LeftSidebar from './components/LeftSidebar';
import Navbar from './components/Navbar';
import FilePreview from './components/FilePreview';
import ResultPanel from './components/ResultPanel';
import UploadModal from './components/UploadModal';
import UserGuide from './components/UserGuide';
import AuthModal from './components/AuthModal';
import { Navigate } from 'react-router-dom';
const PAGE_SIZE = 6;
const GUEST_USAGE_LIMIT = 3;
const GUEST_USAGE_COUNT_KEY = 'texpixel_guest_usage_count';
function App() {
const { user, initializing } = useAuth();
const { t } = useLanguage();
const [files, setFiles] = useState<FileRecord[]>([]);
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showUserGuide, setShowUserGuide] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [loading, setLoading] = useState(false);
const [guestUsageCount, setGuestUsageCount] = useState<number>(() => {
const storedCount = localStorage.getItem(GUEST_USAGE_COUNT_KEY);
const parsedCount = storedCount ? Number.parseInt(storedCount, 10) : 0;
return Number.isFinite(parsedCount) ? parsedCount : 0;
});
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
// Layout state
const [sidebarWidth, setSidebarWidth] = useState(320);
const [isResizing, setIsResizing] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
// Polling intervals refs to clear them on unmount
const pollingIntervals = useRef<Record<string, NodeJS.Timeout>>({});
// Ref to track latest selectedFileId for use in polling callbacks (avoids stale closure)
const selectedFileIdRef = useRef<string | null>(null);
// Cache for recognition results (keyed by task_id/file_id)
const resultsCache = useRef<Record<string, RecognitionResult>>({});
// Ref to prevent double loading on mount (React 18 Strict Mode / dependency changes)
const hasLoadedFiles = useRef(false);
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
const canUploadAnonymously = !user && guestUsageCount < GUEST_USAGE_LIMIT;
const openAuthModal = useCallback(() => {
setShowAuthModal(true);
}, []);
const incrementGuestUsage = useCallback(() => {
setGuestUsageCount((prev) => {
const nextCount = prev + 1;
localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(nextCount));
return nextCount;
});
}, []);
useEffect(() => {
const handleStartGuide = () => setShowUserGuide(true);
window.addEventListener('start-user-guide', handleStartGuide);
// Check for first-time user
const hasSeenGuide = localStorage.getItem('hasSeenGuide');
if (!hasSeenGuide) {
setTimeout(() => setShowUserGuide(true), 1500);
localStorage.setItem('hasSeenGuide', 'true');
export default function App() {
return <Navigate to="/" replace />;
}
return () => window.removeEventListener('start-user-guide', handleStartGuide);
}, []);
useEffect(() => {
if (!initializing && user && !hasLoadedFiles.current) {
hasLoadedFiles.current = true;
loadFiles();
}
// Reset when user logs out
if (!user) {
hasLoadedFiles.current = false;
setFiles([]);
setSelectedFileId(null);
setCurrentPage(1);
setHasMore(false);
}
}, [initializing, user]);
useEffect(() => {
// Keep ref in sync with state for polling callbacks
selectedFileIdRef.current = selectedFileId;
if (selectedFileId) {
loadResult(selectedFileId);
} else {
setSelectedResult(null);
}
}, [selectedFileId]);
// Cleanup polling on unmount
useEffect(() => {
return () => {
Object.values(pollingIntervals.current).forEach(clearInterval);
pollingIntervals.current = {};
};
}, []);
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
// If modal is open, let the modal handle paste events to avoid double upload
if (showUploadModal) return;
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
const items = e.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/') || items[i].type === 'application/pdf') {
const file = items[i].getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0) {
handleUpload(files);
}
};
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [guestUsageCount, openAuthModal, showUploadModal, user]);
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
mouseDownEvent.preventDefault();
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const resize = useCallback(
(mouseMoveEvent: MouseEvent) => {
if (isResizing) {
const newWidth = mouseMoveEvent.clientX;
if (newWidth >= 280 && newWidth <= 400) {
setSidebarWidth(newWidth);
}
}
},
[isResizing]
);
useEffect(() => {
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResizing);
return () => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
};
}, [resize, stopResizing]);
// Convert API TaskHistoryItem to internal FileRecord format
const convertToFileRecord = (item: TaskHistoryItem): FileRecord => {
// Map numeric status enum to internal string status
const statusMap: Record<number, FileRecord['status']> = {
[TaskStatus.Pending]: 'pending',
[TaskStatus.Processing]: 'processing',
[TaskStatus.Completed]: 'completed',
[TaskStatus.Failed]: 'failed',
};
return {
id: item.task_id,
user_id: user?.id || null,
filename: item.file_name,
file_path: item.origin_url, // Already signed OSS URL for preview
file_type: 'image/jpeg', // Default, could be derived from file_name
file_size: 0, // Not provided by API
thumbnail_path: null,
status: statusMap[item.status] || 'pending',
created_at: item.created_at,
updated_at: item.created_at,
};
};
// Convert API TaskHistoryItem to internal RecognitionResult format
const convertToRecognitionResult = (item: TaskHistoryItem): RecognitionResult => {
return {
id: item.task_id,
file_id: item.task_id,
markdown_content: item.markdown,
latex_content: item.latex,
mathml_content: item.mathml,
mml: item.mml,
rendered_image_path: item.image_blob || null,
created_at: item.created_at,
};
};
const loadFiles = async () => {
// Don't fetch if user is not logged in
if (!user) return;
setLoading(true);
try {
// Fetch first page only
const data = await uploadService.getTaskList('FORMULA', 1, PAGE_SIZE);
const taskList = data.task_list || [];
const total = data.total || 0;
// Update pagination state
setCurrentPage(1);
setHasMore(taskList.length < total);
if (taskList.length > 0) {
// Convert API data to internal format
const fileRecords = taskList.map(convertToFileRecord);
setFiles(fileRecords);
// Cache all results from the history (they already contain full data)
taskList.forEach(item => {
if (item.status === TaskStatus.Completed) {
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
}
});
// Auto-select first file if none selected
if (!selectedFileId) {
setSelectedFileId(fileRecords[0].id);
}
} else {
setFiles([]);
}
} catch (error) {
console.error('Error loading files:', error);
setFiles([]);
} finally {
setLoading(false);
}
};
const loadMoreFiles = async () => {
// Don't fetch if user is not logged in or already loading
if (!user || loadingMore || !hasMore) return;
setLoadingMore(true);
try {
const nextPage = currentPage + 1;
const data = await uploadService.getTaskList('FORMULA', nextPage, PAGE_SIZE);
const taskList = data.task_list || [];
const total = data.total || 0;
if (taskList.length > 0) {
const newFileRecords = taskList.map(convertToFileRecord);
// Use functional update to get correct total count
setFiles(prev => {
const newFiles = [...prev, ...newFileRecords];
// Check if there are more pages based on new total
setHasMore(newFiles.length < total);
return newFiles;
});
// Cache results
taskList.forEach(item => {
if (item.status === TaskStatus.Completed) {
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
}
});
setCurrentPage(nextPage);
} else {
setHasMore(false);
}
} catch (error) {
console.error('Error loading more files:', error);
} finally {
setLoadingMore(false);
}
};
const loadResult = async (fileId: string) => {
try {
// First check the local cache (populated from history API)
const cachedResult = resultsCache.current[fileId];
if (cachedResult) {
setSelectedResult(cachedResult);
return;
}
// If not in cache, try to fetch from API (for tasks still processing)
try {
const result = await uploadService.getTaskResult(fileId);
if (result.status === TaskStatus.Completed) {
const recognitionResult: RecognitionResult = {
id: fileId,
file_id: fileId,
markdown_content: result.markdown,
latex_content: result.latex,
mathml_content: result.mathml,
mml: result.mml,
rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString(),
};
resultsCache.current[fileId] = recognitionResult;
setSelectedResult(recognitionResult);
} else {
setSelectedResult(null);
}
} catch {
// Task might not exist or still processing
setSelectedResult(null);
}
} catch (error) {
console.error('Error loading result:', error);
setSelectedResult(null);
}
};
const startPolling = (taskNo: string, fileId: string) => {
if (pollingIntervals.current[taskNo]) return;
let attempts = 0;
const maxAttempts = 30;
pollingIntervals.current[taskNo] = setInterval(async () => {
attempts++;
try {
const result = await uploadService.getTaskResult(taskNo);
// Update status in file list
setFiles(prevFiles => prevFiles.map(f => {
if (f.id === fileId) {
let status: FileRecord['status'] = 'processing';
if (result.status === TaskStatus.Completed) status = 'completed';
else if (result.status === TaskStatus.Failed) status = 'failed';
return { ...f, status };
}
return f;
}));
if (result.status === TaskStatus.Completed || result.status === TaskStatus.Failed) {
// Stop polling
clearInterval(pollingIntervals.current[taskNo]);
delete pollingIntervals.current[taskNo];
if (result.status === TaskStatus.Completed) {
// ... (keep existing completion logic)
// Convert API result to internal RecognitionResult format
const recognitionResult: RecognitionResult = {
id: fileId,
file_id: fileId,
markdown_content: result.markdown,
latex_content: result.latex,
mathml_content: result.mathml,
mml: result.mml,
rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString()
};
// Cache the result for later retrieval
resultsCache.current[fileId] = recognitionResult;
// Update UI if this file is currently selected
if (selectedFileIdRef.current === fileId) {
setSelectedResult(recognitionResult);
}
}
} else if (attempts >= maxAttempts) {
// Timeout: Max attempts reached
clearInterval(pollingIntervals.current[taskNo]);
delete pollingIntervals.current[taskNo];
// Mark as failed in UI
setFiles(prevFiles => prevFiles.map(f => {
if (f.id === fileId) {
return { ...f, status: 'failed' };
}
return f;
}));
alert(t.alerts.taskTimeout);
}
} catch (error) {
console.error('Polling error:', error);
// Don't stop polling immediately on network error, but maybe counting errors?
// For simplicity, keep polling until max attempts.
if (attempts >= maxAttempts) {
clearInterval(pollingIntervals.current[taskNo]);
delete pollingIntervals.current[taskNo];
setFiles(prevFiles => prevFiles.map(f => {
if (f.id === fileId) return { ...f, status: 'failed' };
return f;
}));
alert(t.alerts.networkError);
}
}
}, 2000); // Poll every 2 seconds
};
const handleUpload = async (uploadFiles: File[]) => {
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
setLoading(true);
try {
let successfulUploads = 0;
for (const file of uploadFiles) {
// 1. Upload file to OSS (or check duplicate)
const fileHash = await uploadService.calculateMD5(file);
const signatureData = await uploadService.uploadFile(file);
// 2. Create recognition task
const taskData = await uploadService.createRecognitionTask(
signatureData.path,
fileHash,
file.name
);
// Use task_no if available, or file ID from path, or fallback
const fileId = taskData.task_no || crypto.randomUUID();
const newFile: FileRecord = {
id: fileId,
user_id: user?.id || null,
filename: file.name,
// Use local object URL for immediate preview since OSS URL requires signing
file_path: URL.createObjectURL(file),
file_type: file.type,
file_size: file.size,
thumbnail_path: null,
status: 'processing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// Add new file to the list (prepend)
setFiles(prevFiles => [newFile, ...prevFiles]);
setSelectedFileId(newFile.id);
// Start polling for this task
if (taskData.task_no) {
startPolling(taskData.task_no, fileId);
}
successfulUploads += 1;
}
if (!user && successfulUploads > 0) {
incrementGuestUsage();
}
} catch (error) {
console.error('Error uploading files:', error);
alert(`${t.alerts.uploadFailed}: ` + (error instanceof Error ? error.message : 'Unknown error'));
} finally {
setLoading(false);
}
};
if (initializing) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">{t.common.loading}</p>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-gray-50 font-sans text-gray-900 overflow-hidden">
<Navbar />
<div className="flex-1 flex overflow-hidden">
{/* Left Sidebar */}
<div
ref={sidebarRef}
className="flex-shrink-0 bg-white border-r border-gray-200 relative transition-all duration-300 ease-in-out"
style={{ width: sidebarCollapsed ? 64 : sidebarWidth }}
>
<LeftSidebar
files={files}
selectedFileId={selectedFileId}
onFileSelect={setSelectedFileId}
onUploadClick={() => {
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
setShowUploadModal(true);
}}
canUploadAnonymously={canUploadAnonymously}
onRequireAuth={openAuthModal}
isCollapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
onUploadFiles={handleUpload}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMoreFiles}
/>
{/* Resize Handle */}
{!sidebarCollapsed && (
<div
className="absolute right-0 top-0 w-1 h-full cursor-col-resize hover:bg-blue-400 z-50 opacity-0 hover:opacity-100 transition-opacity"
onMouseDown={startResizing}
/>
)}
</div>
{/* Middle Content: File Preview */}
<div className="flex-1 flex min-w-0 flex-col bg-gray-100/50">
<FilePreview file={selectedFile} />
</div>
{/* Right Result: Recognition Result */}
<div className="flex-1 flex min-w-0 flex-col bg-white">
<ResultPanel
result={selectedResult}
fileStatus={selectedFile?.status}
/>
</div>
{showUploadModal && (
<UploadModal
onClose={() => setShowUploadModal(false)}
onUpload={handleUpload}
/>
)}
{showAuthModal && (
<AuthModal
onClose={() => setShowAuthModal(false)}
/>
)}
<UserGuide
isOpen={showUserGuide}
onClose={() => setShowUserGuide(false)}
/>
{loading && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-8">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-900 font-medium">{t.common.processing}</p>
</div>
</div>
)}
</div>
{/* ICP Footer */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 py-2 px-4 text-center">
<a
href="https://beian.miit.gov.cn"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
ICP备2025152973号
</a>
</div>
</div>
);
}
export default App;

View File

@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { authService } from '../lib/authService';
interface AuthModalProps {
onClose: () => void;
@@ -16,7 +17,12 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [localError, setLocalError] = useState('');
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string; verificationCode?: string }>({});
const [sendingCode, setSendingCode] = useState(false);
const [countdown, setCountdown] = useState(0);
const [codeSent, setCodeSent] = useState(false);
const isBusy = useMemo(
() => ['email_signing_in', 'email_signing_up', 'oauth_redirecting', 'oauth_exchanging'].includes(authPhase),
@@ -25,23 +31,91 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
const submitText = mode === 'signup' ? t.auth.signUp : t.auth.signIn;
useEffect(() => {
if (countdown <= 0) {
return;
}
const timer = window.setTimeout(() => {
setCountdown((prev) => Math.max(prev - 1, 0));
}, 1000);
return () => window.clearTimeout(timer);
}, [countdown]);
const resetSignupState = () => {
setVerificationCode('');
setCodeSent(false);
setCountdown(0);
};
const handleSendCode = async () => {
setLocalError('');
const normalizedEmail = email.trim();
if (!normalizedEmail) {
setFieldErrors((prev) => ({ ...prev, email: t.auth.emailRequired }));
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
setFieldErrors((prev) => ({ ...prev, email: t.auth.emailInvalid }));
return;
}
setSendingCode(true);
try {
await authService.sendEmailCode(normalizedEmail);
setCodeSent(true);
setCountdown(60);
setFieldErrors((prev) => ({ ...prev, email: undefined, verificationCode: undefined }));
} catch {
setLocalError(t.auth.sendCodeFailed);
} finally {
setSendingCode(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLocalError('');
const nextFieldErrors: { email?: string; password?: string; confirmPassword?: string; verificationCode?: string } = {};
const normalizedEmail = email.trim();
if (!normalizedEmail) {
nextFieldErrors.email = t.auth.emailRequired;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
nextFieldErrors.email = t.auth.emailInvalid;
}
if (!password) {
nextFieldErrors.password = t.auth.passwordRequired;
}
if (mode === 'signup') {
if (password.length < 6) {
setLocalError(t.auth.passwordHint);
return;
if (password && password.length < 6) {
nextFieldErrors.password = t.auth.passwordHint;
}
if (password !== confirmPassword) {
setLocalError(t.auth.passwordMismatch);
return;
if (!verificationCode.trim()) {
nextFieldErrors.verificationCode = t.auth.verificationCodeRequired;
}
if (!confirmPassword) {
nextFieldErrors.confirmPassword = t.auth.passwordRequired;
} else if (password !== confirmPassword) {
nextFieldErrors.confirmPassword = t.auth.passwordMismatch;
}
}
const result = mode === 'signup' ? await signUp(email, password) : await signIn(email, password);
setFieldErrors(nextFieldErrors);
if (Object.keys(nextFieldErrors).length > 0) {
return;
}
const result = mode === 'signup'
? await signUp(normalizedEmail, password, verificationCode.trim())
: await signIn(normalizedEmail, password);
if (!result.error) {
onClose();
@@ -52,112 +126,366 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
await beginGoogleOAuth();
};
const s = {
overlay: {
position: 'fixed' as const,
inset: 0,
background: 'rgba(31,26,23,0.45)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '16px',
},
card: {
background: '#FFFDF9',
borderRadius: '24px',
boxShadow: '0 24px 64px rgba(198,134,85,0.18)',
maxWidth: '420px',
width: '100%',
padding: '32px',
fontFamily: "'DM Sans', sans-serif",
border: '1px solid #F1E6D8',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
},
title: {
fontSize: '22px',
fontWeight: 700,
color: '#1F1A17',
fontFamily: "'Lora', serif",
},
closeBtn: {
width: '32px',
height: '32px',
borderRadius: '50%',
border: '1px solid #F1E6D8',
background: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#AA9685',
transition: 'all 0.15s',
},
tabs: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '4px',
background: '#F5DDD0',
borderRadius: '14px',
padding: '4px',
marginBottom: '24px',
},
tabActive: {
padding: '8px 12px',
borderRadius: '11px',
border: 'none',
background: '#FFFDF9',
color: '#C8622A',
fontWeight: 600,
fontSize: '14px',
cursor: 'pointer',
fontFamily: "'DM Sans', sans-serif",
boxShadow: '0 1px 4px rgba(200,98,42,0.12)',
transition: 'all 0.15s',
},
tabInactive: {
padding: '8px 12px',
borderRadius: '11px',
border: 'none',
background: 'transparent',
color: '#6F6257',
fontWeight: 500,
fontSize: '14px',
cursor: 'pointer',
fontFamily: "'DM Sans', sans-serif",
transition: 'all 0.15s',
},
fieldGroup: {
marginBottom: '16px',
},
label: {
display: 'block',
fontSize: '13px',
fontWeight: 600,
color: '#6F6257',
marginBottom: '6px',
letterSpacing: '0.02em',
},
input: {
width: '100%',
padding: '10px 14px',
border: '1.5px solid #F1E6D8',
borderRadius: '12px',
fontSize: '15px',
color: '#1F1A17',
background: '#FFFFFF',
outline: 'none',
fontFamily: "'DM Sans', sans-serif",
transition: 'border-color 0.15s',
},
inputError: {
border: '1.5px solid #d97a6a',
},
fieldError: {
marginTop: '4px',
fontSize: '12px',
color: '#c0503c',
},
fieldHint: {
marginTop: '4px',
fontSize: '12px',
color: '#AA9685',
},
codeRow: {
display: 'flex',
gap: '10px',
alignItems: 'stretch',
},
codeButton: {
minWidth: '118px',
padding: '0 14px',
border: '1.5px solid #F1E6D8',
borderRadius: '12px',
background: '#FFFFFF',
color: '#C8622A',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'DM Sans', sans-serif",
cursor: 'pointer',
transition: 'all 0.15s',
whiteSpace: 'nowrap' as const,
},
errorBox: {
padding: '10px 14px',
background: '#fff0ed',
border: '1px solid #f5c0b0',
color: '#c0503c',
borderRadius: '12px',
fontSize: '13px',
fontWeight: 500,
marginBottom: '16px',
},
submitBtn: {
width: '100%',
height: '48px',
background: '#C8622A',
color: 'white',
border: 'none',
borderRadius: '14px',
fontSize: '15px',
fontWeight: 600,
fontFamily: "'DM Sans', sans-serif",
cursor: 'pointer',
boxShadow: '0 2px 12px rgba(200,98,42,0.28)',
transition: 'all 0.2s',
marginBottom: '16px',
},
divider: {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
},
dividerLine: {
flex: 1,
height: '1px',
background: '#F1E6D8',
},
dividerText: {
fontSize: '12px',
color: '#AA9685',
letterSpacing: '0.05em',
},
googleBtn: {
width: '100%',
height: '48px',
border: '1.5px solid #F1E6D8',
borderRadius: '14px',
background: '#FFFFFF',
color: '#1F1A17',
fontSize: '15px',
fontWeight: 500,
fontFamily: "'DM Sans', sans-serif",
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
transition: 'all 0.2s',
},
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">
<div style={s.overlay}>
<div style={s.card}>
<div style={s.header}>
<h2 style={s.title}>
{mode === 'signup' ? t.auth.signUpTitle : t.auth.signInTitle}
</h2>
{!mandatory && (
<button
type="button"
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
style={s.closeBtn}
aria-label="close"
disabled={isBusy}
>
<X size={20} />
<X size={15} />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 mb-4">
<div style={s.tabs}>
<button
type="button"
onClick={() => setMode('signin')}
onClick={() => {
setMode('signin');
setFieldErrors({});
setLocalError('');
resetSignupState();
}}
aria-pressed={mode === 'signin'}
disabled={isBusy}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
mode === 'signin' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
style={mode === 'signin' ? s.tabActive : s.tabInactive}
>
{t.auth.signIn}
</button>
<button
type="button"
onClick={() => setMode('signup')}
onClick={() => {
setMode('signup');
setFieldErrors({});
setLocalError('');
}}
aria-pressed={mode === 'signup'}
disabled={isBusy}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
mode === 'signup' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
style={mode === 'signup' ? s.tabActive : s.tabInactive}
>
{t.auth.signUp}
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="auth-email" className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.email}
</label>
<form onSubmit={handleSubmit} noValidate>
<div style={s.fieldGroup}>
<label htmlFor="auth-email" style={s.label}>{t.auth.email}</label>
<input
id="auth-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onChange={(e) => {
setEmail(e.target.value);
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }));
if (localError) setLocalError('');
}}
style={fieldErrors.email ? { ...s.input, ...s.inputError } : s.input}
placeholder="your@email.com"
required
disabled={isBusy}
/>
{mode === 'signup' && (
<p className="mt-1 text-xs text-gray-500">{t.auth.emailHint}</p>
)}
{fieldErrors.email && <p style={s.fieldError}>{fieldErrors.email}</p>}
{mode === 'signup' && <p style={s.fieldHint}>{t.auth.emailHint}</p>}
</div>
<div>
<label htmlFor="auth-password" className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.password}
</label>
<div style={s.fieldGroup}>
<label htmlFor="auth-password" style={s.label}>{t.auth.password}</label>
<input
id="auth-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onChange={(e) => {
setPassword(e.target.value);
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }));
if (localError) setLocalError('');
}}
style={fieldErrors.password ? { ...s.input, ...s.inputError } : s.input}
placeholder="••••••••"
required
minLength={6}
disabled={isBusy}
/>
{mode === 'signup' && (
<p className="mt-1 text-xs text-gray-500">{t.auth.passwordHint}</p>
)}
{fieldErrors.password && <p style={s.fieldError}>{fieldErrors.password}</p>}
{mode === 'signup' && <p style={s.fieldHint}>{t.auth.passwordHint}</p>}
</div>
{mode === 'signup' && (
<div>
<label htmlFor="auth-password-confirm" className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.confirmPassword}
</label>
<div style={s.fieldGroup}>
<label htmlFor="auth-code" style={s.label}>{t.auth.verificationCode}</label>
<div style={s.codeRow}>
<input
id="auth-code"
type="text"
value={verificationCode}
onChange={(e) => {
const nextValue = e.target.value.replace(/\D/g, '').slice(0, 6);
setVerificationCode(nextValue);
if (fieldErrors.verificationCode) {
setFieldErrors((prev) => ({ ...prev, verificationCode: undefined }));
}
if (localError) setLocalError('');
}}
style={fieldErrors.verificationCode ? { ...s.input, ...s.inputError } : s.input}
placeholder={t.auth.verificationCodePlaceholder}
inputMode="numeric"
autoComplete="one-time-code"
disabled={isBusy}
/>
<button
type="button"
onClick={handleSendCode}
disabled={isBusy || sendingCode || countdown > 0}
style={{
...s.codeButton,
opacity: isBusy || sendingCode || countdown > 0 ? 0.65 : 1,
cursor: isBusy || sendingCode || countdown > 0 ? 'not-allowed' : 'pointer',
}}
>
{sendingCode
? t.common.loading
: countdown > 0
? `${t.auth.resendCode} (${countdown}s)`
: codeSent
? t.auth.resendCode
: t.auth.sendCode}
</button>
</div>
{fieldErrors.verificationCode && <p style={s.fieldError}>{fieldErrors.verificationCode}</p>}
<p style={s.fieldHint}>
{codeSent ? `${t.auth.codeSent}. ${t.auth.verificationCodeHint}` : t.auth.verificationCodeHint}
</p>
</div>
)}
{mode === 'signup' && (
<div style={s.fieldGroup}>
<label htmlFor="auth-password-confirm" style={s.label}>{t.auth.confirmPassword}</label>
<input
id="auth-password-confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onChange={(e) => {
setConfirmPassword(e.target.value);
if (fieldErrors.confirmPassword) setFieldErrors((prev) => ({ ...prev, confirmPassword: undefined }));
if (localError) setLocalError('');
}}
style={fieldErrors.confirmPassword ? { ...s.input, ...s.inputError } : s.input}
placeholder="••••••••"
required
minLength={6}
disabled={isBusy}
/>
{fieldErrors.confirmPassword && <p style={s.fieldError}>{fieldErrors.confirmPassword}</p>}
</div>
)}
{(localError || authError) && (
<div className="p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm font-medium">
<div style={s.errorBox}>
{t.auth.error}: {localError || authError}
</div>
)}
@@ -165,31 +493,28 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
<button
type="submit"
disabled={isBusy}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait"
style={{ ...s.submitBtn, opacity: isBusy ? 0.75 : 1, cursor: isBusy ? 'wait' : 'pointer' }}
>
{submitText}
</button>
<div className="relative py-1">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-white px-2 text-gray-400">OR</span>
</div>
<div style={s.divider}>
<div style={s.dividerLine} />
<span style={s.dividerText}>OR</span>
<div style={s.dividerLine} />
</div>
<button
type="button"
onClick={handleGoogleOAuth}
disabled={isBusy}
className="w-full py-3 px-4 border border-gray-300 text-gray-800 rounded-lg hover:bg-gray-50 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait inline-flex items-center justify-center gap-2"
style={{ ...s.googleBtn, opacity: isBusy ? 0.75 : 1, cursor: isBusy ? 'wait' : 'pointer' }}
>
<img
src="https://upload.wikimedia.org/wikipedia/commons/7/7e/Gmail_icon_%282020%29.svg"
alt=""
aria-hidden="true"
className="w-[18px] h-[18px]"
style={{ width: '18px', height: '18px' }}
loading="lazy"
decoding="async"
/>

View File

@@ -1,9 +1,8 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
import { Upload, FileText, Clock, ChevronLeft, ChevronRight, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { FileRecord } from '../types';
import AuthModal from './AuthModal';
interface LeftSidebarProps {
files: FileRecord[];
@@ -18,6 +17,8 @@ interface LeftSidebarProps {
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
historyEnabled: boolean;
onToggleHistory: () => void;
}
export default function LeftSidebar({
@@ -33,22 +34,15 @@ export default function LeftSidebar({
hasMore,
loadingMore,
onLoadMore,
historyEnabled,
onToggleHistory,
}: LeftSidebarProps) {
const { user, signOut, isAuthenticated } = useAuth();
const { user } = useAuth();
const { t } = useLanguage();
const [showAuthModal, setShowAuthModal] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const displayName = user?.username?.trim() || user?.email || '';
useEffect(() => {
if (isAuthenticated) {
setShowAuthModal(false);
}
}, [isAuthenticated]);
// ... (rest of the logic remains the same)
// Handle scroll to load more
const handleScroll = useCallback(() => {
if (!listRef.current || loadingMore || !hasMore) return;
@@ -149,19 +143,7 @@ export default function LeftSidebar({
<Upload size={20} />
</button>
<div className="flex-1 w-full flex flex-col items-center gap-4">
<button className="p-2 text-gray-400 hover:text-gray-900 transition-colors" title={t.common.history}>
<History size={20} />
</button>
</div>
<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>
<div className="flex-1 w-full flex flex-col items-center gap-4" />
</div>
);
}
@@ -236,20 +218,41 @@ export default function LeftSidebar({
{/* Middle Area: History */}
<div className="flex-1 overflow-hidden flex flex-col px-4" id="sidebar-history">
<div className="flex items-center gap-2 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
<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>
<div
ref={listRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto space-y-1 pr-2 -mr-2 custom-scrollbar"
>
{!user ? (
{!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.pleaseLogin}
{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">
@@ -304,42 +307,7 @@ export default function LeftSidebar({
)}
</div>
</div>
{/* Bottom Area: User/Login */}
<div className="p-4 border-t border-gray-100 bg-gray-50/30">
{user ? (
<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>
) : (
<button
onClick={() => setShowAuthModal(true)}
className="w-full py-2.5 px-4 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors flex items-center justify-center gap-2 text-sm font-medium shadow-lg shadow-gray-900/10"
>
<LogIn size={18} />
{t.common.login}
</button>
)}
</div>
</div>
{showAuthModal && (
<AuthModal onClose={() => setShowAuthModal(false)} />
)}
</>
);
}

View File

@@ -36,9 +36,7 @@ export default function Navbar() {
<div className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0 z-[60] relative">
{/* Left: Logo */}
<div className="flex items-center gap-2">
<span className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white font-serif italic text-lg shadow-blue-600/30 shadow-md">
T
</span>
<img src="/texpixel-app-icon.svg" alt="TexPixel" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900 tracking-tight">
TexPixel
</span>

View File

@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from '../../App';
import { uploadService } from '../../lib/uploadService';
const { useAuthMock } = vi.hoisted(() => ({
useAuthMock: vi.fn(),
@@ -57,7 +58,7 @@ vi.mock('../../components/LeftSidebar', () => ({
}));
vi.mock('../../components/FilePreview', () => ({
default: () => <div>preview</div>,
default: ({ file }: { file: { id: string } | null }) => <div>{file ? `preview:${file.id}` : 'preview-empty'}</div>,
}));
vi.mock('../../components/ResultPanel', () => ({
@@ -110,3 +111,48 @@ describe('App anonymous usage limit', () => {
expect(screen.queryByText('upload-modal')).not.toBeInTheDocument();
});
});
describe('App initial selection', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
localStorage.setItem('hasSeenGuide', 'true');
});
it('does not auto-select the first history record on initial load', async () => {
useAuthMock.mockReturnValue({
user: { id: 'u1' },
initializing: false,
});
vi.mocked(uploadService.getTaskList).mockResolvedValue({
total: 1,
task_list: [
{
task_id: 'task-1',
file_name: 'sample.png',
status: 2,
origin_url: 'https://example.com/sample.png',
task_type: 'FORMULA',
created_at: '2026-03-06T00:00:00Z',
latex: '',
markdown: 'content',
mathml: '',
mml: '',
image_blob: '',
docx_url: '',
pdf_url: '',
},
],
});
render(<App />);
await waitFor(() => {
expect(uploadService.getTaskList).toHaveBeenCalled();
});
expect(screen.getByText('preview-empty')).toBeInTheDocument();
expect(screen.queryByText('preview:task-1')).not.toBeInTheDocument();
});
});

View File

@@ -1,17 +1,30 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AuthModal from '../AuthModal';
const useAuthMock = vi.fn();
const signInMock = vi.fn().mockResolvedValue({ error: null });
const signUpMock = vi.fn().mockResolvedValue({ error: null });
const beginGoogleOAuthMock = vi.fn().mockResolvedValue({ error: null });
const sendEmailCodeMock = vi.fn().mockResolvedValue(undefined);
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => useAuthMock(),
}));
vi.mock('../../lib/authService', () => ({
authService: {
sendEmailCode: (...args: unknown[]) => sendEmailCodeMock(...args),
},
}));
vi.mock('../../contexts/LanguageContext', () => ({
useLanguage: () => ({
t: {
common: {
loading: '加载中...',
},
auth: {
signIn: '登录',
signUp: '注册',
@@ -28,35 +41,83 @@ vi.mock('../../contexts/LanguageContext', () => ({
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
confirmPassword: '确认密码',
passwordMismatch: '两次输入的密码不一致。',
emailRequired: '请输入邮箱地址。',
emailInvalid: '请输入有效的邮箱地址。',
passwordRequired: '请输入密码。',
sendCode: '发送验证码',
resendCode: '重新发送',
codeSent: '验证码已发送',
verificationCode: '验证码',
verificationCodePlaceholder: '请输入 6 位验证码',
verificationCodeRequired: '请输入验证码。',
verificationCodeHint: '请查收邮箱中的 6 位验证码。',
sendCodeFailed: '发送验证码失败,请重试。',
oauthRedirecting: '正在跳转 Google...',
},
},
}),
}));
describe('AuthModal', () => {
it('renders google oauth button', () => {
useAuthMock.mockReturnValue({
signIn: vi.fn().mockResolvedValue({ error: null }),
signUp: vi.fn().mockResolvedValue({ error: null }),
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
const createAuthState = (overrides?: Record<string, unknown>) => ({
signIn: signInMock,
signUp: signUpMock,
beginGoogleOAuth: beginGoogleOAuthMock,
authPhase: 'idle',
authError: null,
...overrides,
});
describe('AuthModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('requires verification code before signup submit', async () => {
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: '注册', pressed: false }));
fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'password123' } });
fireEvent.change(screen.getByLabelText('确认密码'), { target: { value: 'password123' } });
fireEvent.click(document.querySelector('button[type="submit"]') as HTMLButtonElement);
expect(await screen.findByText('请输入验证码。')).toBeInTheDocument();
expect(signUpMock).not.toHaveBeenCalled();
});
it('sends verification code in signup mode', async () => {
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: '注册', pressed: false }));
fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'test@example.com' } });
fireEvent.click(screen.getByRole('button', { name: '发送验证码' }));
expect(sendEmailCodeMock).toHaveBeenCalledWith('test@example.com');
expect(await screen.findByText(/验证码已发送/)).toBeInTheDocument();
});
it('shows email required message for empty signin submit', async () => {
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
fireEvent.click(document.querySelector('button[type="submit"]') as HTMLButtonElement);
expect(await screen.findByText('请输入邮箱地址。')).toBeInTheDocument();
expect(signInMock).not.toHaveBeenCalled();
});
it('renders google oauth button', () => {
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Google' })).toBeInTheDocument();
});
it('disables inputs and submit while oauth redirecting', () => {
useAuthMock.mockReturnValue({
signIn: vi.fn().mockResolvedValue({ error: null }),
signUp: vi.fn().mockResolvedValue({ error: null }),
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
authPhase: 'oauth_redirecting',
authError: null,
});
useAuthMock.mockReturnValue(createAuthState({ authPhase: 'oauth_redirecting' }));
render(<AuthModal onClose={vi.fn()} />);
@@ -68,13 +129,7 @@ describe('AuthModal', () => {
});
it('switches between signin and signup with segmented tabs', () => {
useAuthMock.mockReturnValue({
signIn: vi.fn().mockResolvedValue({ error: null }),
signUp: vi.fn().mockResolvedValue({ error: null }),
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
authPhase: 'idle',
authError: null,
});
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
@@ -85,13 +140,7 @@ describe('AuthModal', () => {
});
it('shows friendlier signup guidance', () => {
useAuthMock.mockReturnValue({
signIn: vi.fn().mockResolvedValue({ error: null }),
signUp: vi.fn().mockResolvedValue({ error: null }),
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
authPhase: 'idle',
authError: null,
});
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);

View File

@@ -0,0 +1,97 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
const GUIDES = [
{
slug: 'image-to-latex',
svgPaths: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</>
),
titleEn: 'How to convert image to LaTeX',
titleZh: '图片转 LaTeX 完整指南',
metaEn: '5 min read · Most popular',
metaZh: '5 分钟 · 最受欢迎',
},
{
slug: 'copy-to-word',
svgPaths: (
<>
<rect x="4" y="4" width="16" height="16" rx="2"/>
<path d="M8 9h8M8 12h6M8 15h4"/>
</>
),
titleEn: 'Copy formula to Word — native equation format',
titleZh: '复制公式到 Word — 原生公式格式',
metaEn: '4 min read · Extension users',
metaZh: '4 分钟 · 扩展用户',
},
{
slug: 'ocr-accuracy',
svgPaths: (
<>
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</>
),
titleEn: 'OCR math formula guide — accuracy tips',
titleZh: 'OCR 数学公式指南 — 提高准确度',
metaEn: '6 min read · Power users',
metaZh: '6 分钟 · 进阶用户',
},
{
slug: 'pdf-extraction',
svgPaths: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6M10 12l2 2 4-4"/>
</>
),
titleEn: 'PDF formula extraction — batch workflow',
titleZh: 'PDF 公式批量提取工作流',
metaEn: '8 min read · Desktop users',
metaZh: '8 分钟 · 桌面版用户',
},
];
export default function DocsSeoSection() {
const { language } = useLanguage();
const zh = language === 'zh';
return (
<section className="docs-seo" id="docs">
<div className="container">
<div className="section-header reveal">
<div className="eyebrow">Guides</div>
<h2 className="section-title">Image to LaTeX Guides</h2>
<p className="section-desc">
{zh
? '为学生、研究者和数学写作者准备的一步步工作流指南。'
: 'Step-by-step workflows for students, researchers, and anyone writing math.'}
</p>
</div>
<div className="doc-cards reveal">
{GUIDES.map((g, i) => (
<Link key={i} to={`/docs/${g.slug}`} className="doc-card">
<div className="doc-card-left">
<div className="doc-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
{g.svgPaths}
</svg>
</div>
<div>
<div className="doc-title">{zh ? g.titleZh : g.titleEn}</div>
<div className="doc-meta">{zh ? g.metaZh : g.metaEn}</div>
</div>
</div>
<div className="doc-read">{zh ? '阅读指南 →' : 'Read Guide →'}</div>
</Link>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,62 @@
import { useLanguage } from '../../contexts/LanguageContext';
export default function FeaturesSection() {
const { language } = useLanguage();
return (
<section className="core-features">
<div className="container">
<div className="section-header reveal">
<div className="eyebrow">Core Features</div>
<h2 className="section-title">
{language === 'zh'
? '学生留下来的三个理由'
: 'Three reasons students stay with TexPixel'}
</h2>
</div>
<div className="cards-3">
<div className="feature-card reveal reveal-delay-1">
<div className="feature-mini">
<span className="feature-speed"> t &lt; 1s</span>
</div>
<div className="card-title">{language === 'zh' ? '极速识别' : 'Sub-second Recognition'}</div>
<div className="card-desc">
{language === 'zh'
? '上传截图LaTeX 随即出现。拍下笔记,无需等待,直接复制。'
: 'Upload a screenshot, LaTeX appears instantly. Take a photo of your notes and copy right away.'}
</div>
</div>
<div className="feature-card reveal reveal-delay-2">
<div className="feature-mini" style={{ fontSize: '13px', color: 'var(--text-body)' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", color: 'var(--primary)' }}>
\int_0^1 x^2 dx
</span>
</div>
<div className="card-title">{language === 'zh' ? '复杂公式支持' : 'Complex Formula Support'}</div>
<div className="card-desc">
{language === 'zh'
? '矩阵、积分、求和、化学式全部支持。多行公式对齐、角标嵌套一次识别。'
: 'Matrices, integrals, summations, chemical formulas — all supported. Multi-line alignment and nested scripts in one pass.'}
</div>
</div>
<div className="feature-card reveal reveal-delay-3">
<div className="feature-mini">
<span style={{ fontFamily: "'JetBrains Mono', monospace", color: 'var(--text-body)', fontSize: '13px' }}>
\mathbf{'{A}'}<sup style={{ fontSize: '9px', color: 'var(--teal)' }}>1</sup>b
</span>
</div>
<div className="card-title">{language === 'zh' ? '高准确度' : 'High Accuracy'}</div>
<div className="card-desc">
{language === 'zh'
? '论文级别识别准确率。在 arXiv 截图测试集上准确率超过 95%,持续迭代提升中。'
: 'Publication-grade accuracy. Over 95% on arXiv screenshot benchmarks, continuously improving.'}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
const SLIDES = [
{
preview: '/demo/preview-formula.png',
previewAlt: 'Quadratic formula',
latex: '<span class="code-kw" style="color: black;">x = \\frac{- b \\pm \\sqrt{b^{2} - 4 a c}}{2 a}</span>',
},
{
preview: '/demo/preview-chinese.png',
previewAlt: 'Chinese text with integral formula',
latex: '设函数 $f(x)$ 在 $[a,b]$ 上连续,则<br><span class="code-kw" style="color: black;">\\int_a^b f(x)\\dx</span>',
},
{
preview: '/demo/preview-english.png',
previewAlt: 'English text with Maxwell equations',
latex: 'According to Maxwell\'s equations, the curl of the electric field is:<br><span class="code-kw" style="color: black;">\\nabla \\times \\mathbf{E} = - \\frac{\\partial \\mathbf{B}}{\\partial t}</span>',
},
];
export default function HeroSection() {
const { language } = useLanguage();
const codeRef = useRef<HTMLDivElement>(null);
const [slideIdx, setSlideIdx] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSlideIdx((i) => (i + 1) % SLIDES.length);
}, 3500);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (codeRef.current) {
codeRef.current.innerHTML = SLIDES[slideIdx].latex + '<span class="cursor-blink"></span>';
}
}, [slideIdx]);
return (
<section className="hero">
<div className="container">
<div className="hero-inner">
<div className="hero-left">
<h1 className="hero-title">
{language === 'zh' ? (
<><br /><br /><em className="latex-word">LaTeX</em></>
) : (
<>Math Formulas<br />Converted to<br /><em className="latex-word">LaTeX</em></>
)}
</h1>
<p className="hero-desc">
{language === 'zh'
? 'AI 驱动的复杂数学公式识别,支持手写与论文截图,毫秒级输出 LaTeX、Markdown 与 Word 原生公式。'
: 'AI-powered recognition for complex math formulas. Supports handwriting and paper screenshots. Instant LaTeX, Markdown, and Word output.'}
</p>
<div className="hero-actions">
<Link to="/app" className="btn btn-primary">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M5 3l14 9-14 9V3z" />
</svg>
{language === 'zh' ? '免费试用 TexPixel' : 'Try TexPixel Free'}
</Link>
<a href="#products" className="btn btn-secondary">
{language === 'zh' ? '了解更多 →' : 'Learn More →'}
</a>
</div>
<div className="hero-trust">
<div className="trust-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<circle cx="12" cy="12" r="10" /><path d="M12 8v4l3 3" />
</svg>
{language === 'zh' ? '秒级输出' : 'Sub-second output'}
</div>
<div className="trust-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{language === 'zh' ? '支持 PDF · 手写 · 截图' : 'PDF · Handwriting · Screenshots'}
</div>
<div className="trust-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M20 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="1" />
</svg>
{language === 'zh' ? '免费套餐可用' : 'Free plan available'}
</div>
</div>
</div>
<div className="hero-right">
<div className="mock-window">
<div className="window-topbar">
<div className="window-dots">
<div className="window-dot wd-red" />
<div className="window-dot wd-yellow" />
<div className="window-dot wd-green" />
</div>
<div className="window-url">
<svg className="url-lock" viewBox="0 0 12 14">
<rect x="1" y="6" width="10" height="7" rx="2" />
<path d="M3 6V4a3 3 0 0 1 6 0v2" fill="none" stroke="#8CC9BE" strokeWidth="1.4" />
</svg>
texpixel.com/app
</div>
</div>
<div className="window-body">
{/* Upload zone — shows the uploaded document preview */}
<div className="upload-zone upload-zone-preview">
{SLIDES.map((slide, i) => (
<img
key={slide.preview}
src={slide.preview}
alt={slide.previewAlt}
className={`upload-preview-img${i === slideIdx ? ' upload-preview-active' : ''}`}
/>
))}
</div>
{/* Output zone — LaTeX result */}
<div className="output-zone">
<div className="output-header">
<span className="output-label">LaTeX Output</span>
<span className="output-badge">{language === 'zh' ? '识别完成' : 'Done'}</span>
</div>
<div
className="output-code"
ref={codeRef}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: SLIDES[0].latex + '<span class="cursor-blink"></span>',
}}
/>
<div className="output-actions">
<button className="output-btn output-btn-copy">
{language === 'zh' ? '复制 LaTeX' : 'Copy LaTeX'}
</button>
<button className="output-btn output-btn-word">
{language === 'zh' ? '复制到 Word' : 'Copy to Word'}
</button>
</div>
</div>
<div className="window-status">
<div className="status-time">{language === 'zh' ? '识别耗时 0.38s' : 'Recognized in 0.38s'}</div>
<div className="status-format">
<div className="fmt-tag">LaTeX</div>
<div className="fmt-tag">Markdown</div>
<div className="fmt-tag">Word</div>
</div>
</div>
{/* Slide indicator dots */}
<div className="window-slide-dots">
{SLIDES.map((_, i) => (
<button
key={i}
className={`window-dot-btn${i === slideIdx ? ' window-dot-active' : ''}`}
onClick={() => setSlideIdx(i)}
aria-label={`Demo ${i + 1}`}
/>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,132 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
export default function PricingSection() {
const { language } = useLanguage();
const zh = language === 'zh';
return (
<section className="pricing" id="pricing">
<div className="container">
<div className="section-header reveal">
<div className="eyebrow">Pricing</div>
<h2 className="section-title">
{zh ? '选择适合你的方案' : 'Choose the plan that fits your workflow'}
</h2>
<p className="section-desc">
{zh
? '从免费试用到永久授权,按需选择,无需绑定订阅。'
: 'From free trial to lifetime license — pick what fits, no subscription lock-in.'}
</p>
</div>
{/* Beta notice */}
<div className="pricing-beta-notice reveal">
<span className="pricing-beta-icon">🎉</span>
<span>
{zh
? '内测期间全部方案免费开放 · 无需绑定支付方式,注册即可解锁全部功能'
: 'Free during beta · No payment required — sign up and unlock all features now'}
</span>
</div>
<div className="pricing-cards">
{/* Free */}
<div className="pricing-card reveal reveal-delay-1">
<div className="plan-name">Free</div>
<div className="plan-price">
{zh ? <><span>¥</span>0</> : <><span>$</span>0</>}
</div>
<div className="plan-period">{zh ? '永久免费' : 'Forever free'}</div>
<div className="plan-edu-price plan-edu-price-placeholder">&nbsp;</div>
<ul className="plan-features">
<li>{zh ? '每月 30 次识别' : '30 recognitions / month'}</li>
<li>LaTeX {zh ? '输出' : 'output'}</li>
<li>Web App {zh ? '访问' : 'access'}</li>
<li>{zh ? '基础公式支持' : 'Basic formula support'}</li>
</ul>
<Link to="/app" className="plan-btn">{zh ? '免费体验' : 'Try Free'}</Link>
</div>
{/* Monthly */}
<div className="pricing-card reveal reveal-delay-2">
<div className="plan-ribbon plan-ribbon-free">
{zh ? '限时免费' : 'Free Now'}
</div>
<div className="plan-name">{zh ? '月度会员' : 'Monthly'}</div>
<div className="plan-price">
{zh
? <><span>RMB ¥</span>19.9</>
: <><span>$</span>2.99</>}
</div>
<div className="plan-edu-price">
{zh ? '教育优惠 ¥12.9 / 月' : 'Edu discount $1.99 / mo'}
</div>
<ul className="plan-features">
<li>{zh ? '每月 1,000 次请求' : '1,000 requests / month'}</li>
<li>{zh ? '优先处理队列' : 'Priority processing'}</li>
<li>{zh ? '插件使用权限' : 'Plugin access'}</li>
</ul>
<Link to="/app" className="plan-btn">{zh ? '免费体验' : 'Try Free'}</Link>
</div>
{/* Quarterly — featured */}
<div className="pricing-card featured reveal reveal-delay-3">
<div className="featured-badge">{zh ? '最优惠' : 'Best Value'}</div>
<div className="plan-ribbon plan-ribbon-free">
{zh ? '限时免费' : 'Free Now'}
</div>
<div className="plan-name">{zh ? '季度会员' : 'Quarterly'}</div>
<div className="plan-price">
{zh
? <><span>RMB ¥</span>49.9</>
: <><span>$</span>9.9</>}
</div>
<div className="plan-edu-price">
{zh ? '教育优惠 ¥29.9 / 季' : 'Edu discount $7.99 / quarter'}
</div>
<ul className="plan-features">
<li>{zh ? '每月 3,000 积分' : '3,000 credits / month'}</li>
<li>{zh ? '优先处理队列' : 'Priority processing'}</li>
<li>{zh ? '插件使用权限' : 'Plugin access'}</li>
<li>API {zh ? '访问Beta' : 'access (Beta)'}</li>
</ul>
<Link to="/app" className="plan-btn featured-btn">{zh ? '免费体验' : 'Try Free'}</Link>
</div>
{/* Lifetime License — coming soon */}
<div className="pricing-card pricing-card-desktop reveal">
<div className="plan-ribbon plan-ribbon-soon">Coming Soon</div>
<div className="plan-desktop-tag">
<span>🖥</span>
{zh ? '桌面应用 · 离线运行' : 'Desktop App · Offline'}
</div>
<div className="plan-name">{zh ? '永久授权' : 'Lifetime License'}</div>
<div className="plan-price">
{zh
? <><span>RMB ¥</span>149</>
: <><span>$</span>29.9</>}
</div>
<div className="plan-period">
{zh ? '一次购买 · 终身使用' : 'one-time · lifetime access'}
</div>
<div className="plan-edu-price">
{zh ? '教育优惠 ¥99' : 'Edu discount $24.9'}
</div>
<ul className="plan-features">
<li>{zh ? '完全离线运行' : 'Fully offline'}</li>
<li>{zh ? '无限次识别' : 'Unlimited recognitions'}</li>
<li>{zh ? '本地隐私保护' : 'Local privacy'}</li>
<li>{zh ? '终身免费更新' : 'Lifetime free updates'}</li>
</ul>
<button className="plan-btn plan-btn-desktop-disabled" disabled>
{zh ? '敬请期待' : 'Coming Soon'}
</button>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,81 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
export default function ProductSuiteSection() {
const { language } = useLanguage();
return (
<section className="product-suite" id="products">
<div className="container">
<div className="section-header reveal">
<div className="eyebrow">Product Matrix</div>
<h2 className="section-title">
{language === 'zh' ? '三端灵活,覆盖所有使用场景' : 'Three ways to use, every workflow covered'}
</h2>
<p className="section-desc">
{language === 'zh'
? '浏览器即开即用,扩展随手一键,桌面端离线无忧——学生轻松完成作业,教师高效备课制件,研究者快速整理文献,总有一种方式最适合你。'
: 'Browser-ready, extension-powered, desktop offline — for students finishing homework, teachers preparing materials, and researchers organizing papers.'}
</p>
</div>
<div className="cards-3">
<div className="product-card reveal reveal-delay-1">
<div className="card-icon icon-orange">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
</div>
<div className="card-title">Web App</div>
<div className="card-desc">
{language === 'zh'
? '无需安装,打开浏览器即可使用。上传截图或手写图片,秒级输出 LaTeX——学生写作业、教师课堂即时识别都毫不费力。'
: 'No install needed. Upload a screenshot or handwriting, get LaTeX in seconds — perfect for students and teachers alike.'}
</div>
<Link to="/app" className="card-link">
{language === 'zh' ? '浏览器即时识别 →' : 'Instant formula recognition in browser →'}
</Link>
</div>
<div className="product-card reveal reveal-delay-2">
<div className="card-icon icon-teal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="M8 13h8M8 17h5" />
</svg>
</div>
<div className="card-title">Extension</div>
<div className="card-desc">
{language === 'zh'
? '一键将 ChatGPT、Claude 输出的公式转为 Word 原生数学公式。教师备课制件、学生写报告,不再手动排版。'
: 'One-click copy of formulas from ChatGPT or Claude as native Word equations — ideal for teachers making slides and students writing reports.'}
</div>
<a href="#" className="card-link">
{language === 'zh' ? '从 LLM 复制公式到 Word →' : 'Copy formulas from LLMs to Word →'}
</a>
</div>
<div className="product-card reveal reveal-delay-3">
<div className="card-icon icon-lavender">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M2 8h20M8 3v5" />
</svg>
</div>
<div className="card-title">Desktop</div>
<div className="card-desc">
{language === 'zh'
? '本地离线运行,数据不出机。研究者批量提取论文公式、保护敏感数据的首选。一次购买,终身使用。'
: 'Fully offline, data stays local. The go-to for researchers extracting formulas from papers at scale. One-time purchase.'}
</div>
<a href="#pricing" className="card-link">
{language === 'zh' ? '查看桌面版定价 →' : 'See Desktop pricing →'}
</a>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,116 @@
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
const DEMO_CASES = [
{
key: 'complex',
src: '/fomula_demo/complex.png',
tag: { zh: '复杂公式', en: 'Complex' },
title: { zh: '嵌套数学公式', en: 'Nested Math' },
desc: { zh: 'arXiv 论文截图', en: 'arXiv paper screenshot' },
color: 'var(--primary)',
colorBg: 'rgba(200,98,42,0.10)',
},
{
key: 'deformity',
src: '/fomula_demo/deformity.png',
tag: { zh: '扭曲变形', en: 'Distorted' },
title: { zh: '倾斜扫描识别', en: 'Skewed Scan' },
desc: { zh: '低质量扫描文档', en: 'Low-quality scanned doc' },
color: 'var(--teal)',
colorBg: 'rgba(140,201,190,0.13)',
},
{
key: 'maual',
src: '/fomula_demo/maual.png',
tag: { zh: '手写公式', en: 'Handwriting Formula' },
title: { zh: '手写转 LaTeX', en: 'Handwriting → LaTeX' },
desc: { zh: '手机拍摄课堂笔记', en: 'Phone-captured notes' },
color: 'var(--gold)',
colorBg: 'rgba(243,201,106,0.15)',
},
{
key: 'mix',
src: '/fomula_demo/mix.png',
tag: { zh: '文字混排', en: 'Text Layout' },
title: { zh: '文字混排识别', en: 'Text + Formula' },
desc: { zh: '论文正文截图', en: 'Paper body with equations' },
color: 'var(--lavender)',
colorBg: 'rgba(183,175,232,0.15)',
},
] as const;
export default function ShowcaseSection() {
const { language } = useLanguage();
const navigate = useNavigate();
const handleTryIt = (src: string) => {
sessionStorage.setItem('texpixel_demo_image', src);
navigate('/app');
};
return (
<section className="showcase">
<div className="container">
<div className="section-header reveal">
<div className="eyebrow">Live Examples</div>
<h2 className="section-title">
{language === 'zh' ? '真实案例演示' : 'Try Real Examples'}
</h2>
<p className="section-desc">
{language === 'zh'
? '点击任意案例,直接体验 TexPixel 的识别效果。'
: 'Click any example to instantly try TexPixel on real formula images.'}
</p>
</div>
<div className="demo-grid">
{DEMO_CASES.map((c, i) => (
<div
key={c.key}
className={`demo-card reveal reveal-delay-${i + 1}`}
>
<div className="demo-img-wrap">
<img src={c.src} alt={c.tag[language]} className="demo-img" />
<div className="demo-img-overlay">
<button
className="demo-try-btn"
onClick={() => handleTryIt(c.src)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M5 3l14 9-14 9V3z" />
</svg>
{language === 'zh' ? '立即试用' : 'Try it now'}
</button>
</div>
</div>
<div className="demo-meta">
<span
className="demo-tag"
style={{ color: c.color, background: c.colorBg }}
>
{c.tag[language]}
</span>
<div className="demo-title">{c.title[language]}</div>
<div className="demo-desc">{c.desc[language]}</div>
</div>
<div className="demo-footer">
<button
className="demo-cta"
onClick={() => handleTryIt(c.src)}
>
{language === 'zh' ? '试试这个例子' : 'Try this example'}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,192 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
const TESTIMONIALS = [
{
quote: '写论文的时候截图粘进去LaTeX 就出来了。以前每个公式都要手敲,现在一个截图解决,省了我大概 40% 的时间。',
name: '纪**',
role: '研究生 · 上海交通大学',
avatarBg: 'var(--teal)',
avatarColor: '#1a5c54',
avatarLetter: '纪',
stars: '★★★★★',
},
{
quote: '手写推导拍一张,马上就是干净的 LaTeX。对我这种每天要整理大量笔记的人来说真的是刚需级别的工具。',
name: '李**',
role: '研究生 · 北京航空航天大学',
avatarBg: 'var(--lavender)',
avatarColor: '#3d3870',
avatarLetter: '李',
stars: '★★★★★',
},
{
quote: "I use it every day for my thesis. The accuracy on complex integrals and matrix expressions is surprisingly good — way better than anything I've tried before.",
name: 'E. ***',
role: 'Graduate Student · National University of Singapore',
avatarBg: 'var(--rose)',
avatarColor: '#7a2e1e',
avatarLetter: 'E',
stars: '★★★★★',
},
{
quote: '教材扫描件里的公式以前完全没法用,现在截图一框就搞定。连复杂数学公式都能识别,超出我的预期。',
name: '孟**',
role: '本科生 · 同济大学',
avatarBg: 'var(--gold)',
avatarColor: '#6f5800',
avatarLetter: '孟',
stars: '★★★★☆',
},
{
quote: '做毕设推导的时候,把草稿纸拍照上传,几秒就能得到完整的 LaTeX 代码,再也不用对着键盘一个符号一个符号敲了。',
name: '任**',
role: '本科生 · 天津大学',
avatarBg: '#A8C5A0',
avatarColor: '#1e4a18',
avatarLetter: '任',
stars: '★★★★★',
},
{
quote: '识别精度真的超预期,矩阵、积分、偏导数全都能准确处理,我们组里几个同学现在都在用。',
name: '姚**',
role: '研究生 · 中山大学',
avatarBg: '#B8A9D9',
avatarColor: '#2e1e5c',
avatarLetter: '姚',
stars: '★★★★★',
},
{
quote: '科研文档里经常遇到各种冷门符号,之前其他工具基本认不出来,这个都能处理,识别结果直接可用。',
name: '肖**',
role: '研究生 · 国防科技大学',
avatarBg: '#8CB4C9',
avatarColor: '#1a3d52',
avatarLetter: '肖',
stars: '★★★★★',
},
];
const VISIBLE = 3;
const TOTAL = TESTIMONIALS.length;
const PAGES = TOTAL - VISIBLE + 1; // 4
export default function TestimonialsSection() {
const { language } = useLanguage();
const [currentPage, setCurrentPage] = useState(0);
const trackRef = useRef<HTMLDivElement>(null);
const autoTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const goTo = useCallback((idx: number) => {
const clamped = Math.max(0, Math.min(idx, PAGES - 1));
setCurrentPage(clamped);
if (trackRef.current) {
const cardW = trackRef.current.parentElement!.offsetWidth;
const gap = 20;
const singleW = (cardW - gap * (VISIBLE - 1)) / VISIBLE;
const offset = clamped * (singleW + gap);
trackRef.current.style.transform = `translateX(-${offset}px)`;
}
}, []);
const resetAuto = useCallback(() => {
if (autoTimerRef.current) clearInterval(autoTimerRef.current);
autoTimerRef.current = setInterval(() => {
setCurrentPage((prev) => {
const next = (prev + 1) % PAGES;
goTo(next);
return next;
});
}, 5000);
}, [goTo]);
useEffect(() => {
resetAuto();
return () => { if (autoTimerRef.current) clearInterval(autoTimerRef.current); };
}, [resetAuto]);
useEffect(() => {
const handleResize = () => goTo(currentPage);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [currentPage, goTo]);
const handleGoTo = (idx: number) => {
goTo(idx);
resetAuto();
};
return (
<section className="user-love">
<div className="container">
<div className="section-header reveal">
<div className="eyebrow">User Love</div>
<h2 className="section-title">
{language === 'zh' ? '全球学生都在用' : 'Loved by students worldwide'}
</h2>
<p className="section-desc">
{language === 'zh' ? '来自用户的使用体验分享。' : 'What users are saying about their experience.'}
</p>
</div>
<div className="testimonial-wrap reveal">
<div
className="testimonial-track"
ref={trackRef}
style={{ transition: 'transform 0.4s ease' }}
>
{TESTIMONIALS.map((t, i) => (
<div key={i} className="testimonial-card">
<div className="t-quote">"</div>
<p className="t-body">{t.quote}</p>
<div className="t-footer">
<div
className="t-avatar"
style={{ background: t.avatarBg, color: t.avatarColor }}
>
{t.avatarLetter}
</div>
<div>
<div className="t-name">{t.name}</div>
<div className="t-role">{t.role}</div>
</div>
<div className="t-stars">{t.stars}</div>
</div>
</div>
))}
</div>
<div className="testimonial-nav">
<button
className="t-nav-btn"
onClick={() => handleGoTo(currentPage - 1)}
aria-label={language === 'zh' ? '上一条' : 'Previous'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<div className="t-dots">
{Array.from({ length: PAGES }).map((_, i) => (
<div
key={i}
className={`t-dot${i === currentPage ? ' active' : ''}`}
onClick={() => handleGoTo(i)}
/>
))}
</div>
<button
className="t-nav-btn"
onClick={() => handleGoTo(currentPage + 1)}
aria-label={language === 'zh' ? '下一条' : 'Next'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom';
import AppNavbar from './AppNavbar';
export default function AppLayout() {
return (
<div className="h-screen flex flex-col bg-gray-50 font-sans text-gray-900 overflow-hidden">
<AppNavbar />
<div className="flex-1 flex overflow-hidden">
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X, Languages, HelpCircle, Home } from 'lucide-react';
import { useLanguage } from '../../contexts/LanguageContext';
export default function AppNavbar() {
const { language, setLanguage, t } = useLanguage();
const [showContact, setShowContact] = useState(false);
const [showReward, setShowReward] = useState(false);
const [showLangMenu, setShowLangMenu] = useState(false);
const [copied, setCopied] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const langMenuRef = useRef<HTMLDivElement>(null);
const handleCopyQQ = async () => {
await navigator.clipboard.writeText('1018282100');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowContact(false);
}
if (langMenuRef.current && !langMenuRef.current.contains(event.target as Node)) {
setShowLangMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="h-14 bg-white border-b border-cream-300 flex items-center justify-between px-5 flex-shrink-0 z-[60] relative">
{/* 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>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{/* Language Switcher */}
<div className="relative" ref={langMenuRef}>
<button
onClick={() => setShowLangMenu(!showLangMenu)}
className="flex items-center gap-1.5 px-2.5 py-1.5 hover:bg-cream-200/60 rounded-lg text-ink-secondary text-sm font-medium transition-colors"
title="Switch Language"
>
<Languages size={16} />
<span className="hidden sm:inline text-xs">{language === 'en' ? 'EN' : '中文'}</span>
<ChevronDown size={12} className={`transition-transform duration-200 ${showLangMenu ? 'rotate-180' : ''}`} />
</button>
{showLangMenu && (
<div className="absolute right-0 top-full mt-2 w-36 bg-white rounded-xl shadow-lg border border-cream-300 py-1.5 z-50">
<button
onClick={() => { setLanguage('en'); setShowLangMenu(false); }}
className={`w-full flex items-center justify-between px-4 py-2.5 text-sm transition-colors hover:bg-cream-100 ${language === 'en' ? 'text-coral-600 font-semibold' : 'text-ink-secondary'}`}
>
English
{language === 'en' && <Check size={14} />}
</button>
<button
onClick={() => { setLanguage('zh'); setShowLangMenu(false); }}
className={`w-full flex items-center justify-between px-4 py-2.5 text-sm transition-colors hover:bg-cream-100 ${language === 'zh' ? 'text-coral-600 font-semibold' : 'text-ink-secondary'}`}
>
{language === 'zh' && <Check size={14} />}
</button>
</div>
)}
</div>
{/* User Guide Button */}
<button
id="guide-button"
className="flex items-center gap-1.5 px-2.5 py-1.5 hover:bg-cream-200/60 rounded-lg text-ink-secondary text-sm font-medium transition-colors"
onClick={() => {
window.dispatchEvent(new CustomEvent('start-user-guide'));
}}
>
<HelpCircle size={16} />
<span className="hidden sm:inline text-xs">{t.common.guide}</span>
</button>
{/* Reward Button */}
<div className="relative">
<button
onClick={() => setShowReward(!showReward)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gradient-to-r from-coral-500 to-coral-400 hover:from-coral-600 hover:to-coral-500 rounded-lg text-white text-xs font-semibold transition-all shadow-sm hover:shadow-md"
>
<Heart size={13} className="fill-white" />
<span>{t.common.reward}</span>
</button>
{showReward && (
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-[70] p-4"
onClick={() => setShowReward(false)}
>
<div
className="bg-white rounded-2xl shadow-2xl max-w-sm w-full p-6"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<span className="text-lg font-display font-bold text-ink">{t.navbar.rewardTitle}</span>
<button
onClick={() => setShowReward(false)}
className="p-1.5 hover:bg-cream-200 rounded-lg transition-colors"
>
<X size={18} className="text-ink-muted" />
</button>
</div>
<div className="flex flex-col items-center">
<img
src="https://cdn.texpixel.com/public/rewardcode.png"
alt={t.navbar.rewardTitle}
className="w-60 h-60 object-contain rounded-xl"
/>
<p className="text-sm text-ink-secondary text-center mt-4">
{t.navbar.rewardThanks}<br />
<span className="text-xs text-ink-muted mt-1 block">{t.navbar.rewardSubtitle}</span>
</p>
</div>
</div>
</div>
)}
</div>
{/* Contact Button with Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowContact(!showContact)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-cream-200 hover:bg-cream-300 rounded-lg text-ink-secondary text-xs font-semibold transition-colors"
>
<MessageCircle size={13} />
<span>{t.common.contactUs}</span>
<ChevronDown
size={12}
className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`}
/>
</button>
{showContact && (
<div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-cream-300 py-2 z-50">
<a
href="mailto:yogecoder@gmail.com"
className="flex items-center gap-3 px-4 py-3 hover:bg-cream-100 transition-colors"
>
<div className="w-8 h-8 bg-coral-50 rounded-lg flex items-center justify-center">
<Mail size={15} className="text-coral-600" />
</div>
<div>
<div className="text-xs text-ink-muted">{t.common.email}</div>
<div className="text-sm font-medium text-ink">yogecoder@gmail.com</div>
</div>
</a>
<div
className={`flex items-center gap-3 px-4 py-3 hover:bg-cream-100 transition-all cursor-pointer ${copied ? 'bg-sage-50' : ''}`}
onClick={handleCopyQQ}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${copied ? 'bg-sage-500' : 'bg-sage-50'}`}>
{copied ? (
<Check size={15} className="text-white" />
) : (
<Users size={15} className="text-sage-600" />
)}
</div>
<div>
<div className={`text-xs transition-colors ${copied ? 'text-sage-600 font-medium' : 'text-ink-muted'}`}>
{copied ? t.common.copied : t.common.qqGroup}
</div>
<div className="text-sm font-medium text-ink">1018282100</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
export default function Footer() {
const { language } = useLanguage();
const zh = language === 'zh';
return (
<footer>
<div className="container">
<div className="footer-grid">
<div className="footer-brand">
<Link to="/" className="footer-logo">
<div className="logo-icon" style={{ width: '32px', height: '32px', borderRadius: '9px' }}>
<svg viewBox="0 0 24 24" style={{ width: '18px', height: '18px' }} fill="none" stroke="white" strokeWidth="2">
<path d="M4 6h16M4 10h10M4 14h12M4 18h8" />
</svg>
</div>
<span>TexPixel</span>
</Link>
<p className="footer-tagline">
{zh ? '为学生、研究者和数学写作者而设计。' : 'Designed for students, researchers, and anyone writing math.'}
</p>
</div>
<div>
<div className="footer-col-title">Product</div>
<ul className="footer-links">
<li><Link to="/app">Web App</Link></li>
<li><a href="#">Extension</a></li>
<li><a href="#pricing">{zh ? '桌面版' : 'Desktop'}</a></li>
<li><a href="#">API (Beta)</a></li>
</ul>
</div>
<div>
<div className="footer-col-title">Docs</div>
<ul className="footer-links">
<li><Link to="/docs">Image to LaTeX</Link></li>
<li><Link to="/docs">PDF to Markdown</Link></li>
<li><Link to="/docs">{zh ? '手写识别' : 'Handwritten OCR'}</Link></li>
<li><Link to="/docs">Word Equations</Link></li>
</ul>
</div>
<div>
<div className="footer-col-title">{zh ? '公司' : 'Company'}</div>
<ul className="footer-links">
<li><Link to="/about">{zh ? '关于我们' : 'About'}</Link></li>
<li><Link to="/#pricing">{zh ? '价格' : 'Pricing'}</Link></li>
<li><Link to="/blog">{zh ? '博客' : 'Blog'}</Link></li>
<li><Link to="/contact">{zh ? '联系我们' : 'Contact'}</Link></li>
</ul>
</div>
<div>
<div className="footer-col-title">{zh ? '法律' : 'Legal'}</div>
<ul className="footer-links">
<li><Link to="/terms">{zh ? '服务条款' : 'Terms of Service'}</Link></li>
<li><Link to="/privacy">{zh ? '隐私政策' : 'Privacy Policy'}</Link></li>
<li><Link to="/cookies">{zh ? 'Cookie 政策' : 'Cookie Policy'}</Link></li>
</ul>
</div>
</div>
<div className="footer-bottom">
<div className="footer-copy">© 2026 TexPixel. All rights reserved.</div>
<div className="footer-made">
Made with{' '}
<svg viewBox="0 0 24 24" style={{ display: 'inline', width: '14px', height: '14px', verticalAlign: 'middle', fill: 'var(--rose)' }}>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{' '}{zh ? '为全球学生而作' : 'for students worldwide'}
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,19 @@
import { Outlet } from 'react-router-dom';
import MarketingNavbar from './MarketingNavbar';
import Footer from './Footer';
import '../../styles/landing.css';
export default function MarketingLayout() {
return (
<div className="marketing-page">
<div className="glow-blob glow-blob-1" />
<div className="glow-blob glow-blob-2" />
<div className="glow-blob glow-blob-3" />
<MarketingNavbar />
<main>
<Outlet />
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useState, useEffect, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
import { useAuth } from '../../contexts/AuthContext';
import AuthModal from '../AuthModal';
export default function MarketingNavbar() {
const { language, setLanguage } = useLanguage();
const { user, signOut } = useAuth();
const location = useLocation();
const isHome = location.pathname === '/';
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState('');
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
// Scroll: sticky style + active nav section
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 10);
if (!isHome) return;
let current = '';
document.querySelectorAll('section[id]').forEach((s) => {
if (window.scrollY >= (s as HTMLElement).offsetTop - 100) {
current = s.id;
}
});
setActiveSection(current);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [isHome]);
// Close user menu on outside click
useEffect(() => {
const handle = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
setUserMenuOpen(false);
}
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, []);
const navLinks = [
{ href: '/', label: language === 'zh' ? '首页' : 'Home' },
{ href: '/docs', label: language === 'zh' ? '文档' : 'Docs' },
{ href: '/blog', label: language === 'zh' ? '博客' : 'Blog' },
];
const anchorLinks = isHome
? [{ href: '#pricing', label: language === 'zh' ? '价格' : 'Pricing' }]
: [];
return (
<>
<nav style={{ opacity: scrolled ? 1 : undefined }}>
<div className="nav-inner">
<Link to="/" className="nav-logo">
<div className="logo-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2">
<path d="M4 6h16M4 10h10M4 14h12M4 18h8" />
</svg>
</div>
<span style={{ fontFamily: "'Lora', serif" }}>TexPixel</span>
</Link>
<ul className="nav-links">
{navLinks.map((link) => (
<li key={link.href}>
<Link
to={link.href}
className={location.pathname === link.href ? 'active' : ''}
>
{link.label}
</Link>
</li>
))}
{anchorLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
className={activeSection === link.href.slice(1) ? 'active' : ''}
>
{link.label}
</a>
</li>
))}
</ul>
<div className="nav-right">
<button
className="lang-switch"
onClick={() => setLanguage(language === 'zh' ? 'en' : 'zh')}
>
{language === 'zh' ? 'EN' : '中文'}
</button>
{user ? (
<div className="nav-user" ref={userMenuRef} style={{ position: 'relative' }}>
<div
className="nav-avatar"
onClick={() => setUserMenuOpen((o) => !o)}
style={{ cursor: 'pointer' }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
</div>
{userMenuOpen && (
<div className="nav-user-menu" style={{ display: 'block' }}>
<div className="nav-menu-divider" />
<button
className="nav-menu-item nav-menu-logout"
onClick={() => { signOut(); setUserMenuOpen(false); }}
style={{ background: 'none', border: 'none', width: '100%', textAlign: 'left', cursor: 'pointer' }}
>
{language === 'zh' ? '退出登录' : 'Sign Out'}
</button>
</div>
)}
</div>
) : (
<div className="nav-login-btn">
<button className="btn-cta" onClick={() => setShowAuthModal(true)}>
{language === 'zh' ? '登录' : 'Login'}
</button>
</div>
)}
</div>
</div>
</nav>
{showAuthModal && <AuthModal onClose={() => setShowAuthModal(false)} />}
</>
);
}

View File

@@ -0,0 +1,48 @@
import { Helmet } from 'react-helmet-async';
interface SEOHeadProps {
title: string;
description: string;
path: string;
type?: 'website' | 'article';
image?: string;
publishedTime?: string;
noindex?: boolean;
}
const BASE_URL = 'https://texpixel.com';
export default function SEOHead({
title,
description,
path,
type = 'website',
image = 'https://cdn.texpixel.com/public/og-cover.png',
publishedTime,
noindex = false,
}: SEOHeadProps) {
const url = `${BASE_URL}${path}`;
const fullTitle = path === '/' ? title : `${title} | TexPixel`;
return (
<Helmet>
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:type" content={type} />
<meta property="og:image" content={image} />
<meta property="og:site_name" content="TexPixel" />
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
</Helmet>
);
}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, ReactNode, useCallback, useEffect, useReducer } from 'react';
import { createContext, useContext, useMemo, ReactNode, useCallback, useEffect, useReducer, useRef } from 'react';
import { authService } from '../lib/authService';
import { ApiErrorMessages } from '../types/api';
import type { GoogleOAuthCallbackRequest, UserInfo } from '../types/api';
@@ -15,7 +15,7 @@ interface AuthContextType {
authPhase: AuthPhase;
authError: string | null;
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
signUp: (email: string, password: string, code: string) => Promise<{ error: Error | null }>;
beginGoogleOAuth: () => Promise<{ error: Error | null }>;
completeGoogleOAuth: (params: GoogleOAuthCallbackRequest) => Promise<{ error: Error | null }>;
signOut: () => Promise<void>;
@@ -74,11 +74,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, []);
const signUp = useCallback(async (email: string, password: string) => {
const signUp = useCallback(async (email: string, password: string, code: string) => {
dispatch({ type: 'EMAIL_SIGNUP_START' });
try {
const result = await authService.register({ email, password });
const result = await authService.register({ email, password, code });
dispatch({ type: 'EMAIL_SIGNUP_SUCCESS', payload: { user: result.user, token: result.token } });
return { error: null };
} catch (error) {
@@ -147,14 +147,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
dispatch({ type: 'SIGN_OUT' });
}, []);
useEffect(() => {
let cancelled = false;
const syncedTokenRef = useRef<string | null>(null);
const syncUserProfile = async () => {
if (!state.user || !state.token) {
useEffect(() => {
const currentUser = state.user;
const currentToken = state.token;
if (!currentUser || !currentToken) {
return;
}
// 已经为当前 token 同步过,跳过(防止 StrictMode 双调用或 effect 重复执行)
if (syncedTokenRef.current === currentToken) {
return;
}
syncedTokenRef.current = currentToken;
let cancelled = false;
const syncUserProfile = async () => {
try {
const profile = await authService.getUserInfo();
if (cancelled) {
@@ -164,11 +175,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
dispatch({
type: 'UPDATE_USER',
payload: {
user: mergeUserProfile(state.user, profile),
user: mergeUserProfile(currentUser, profile),
},
});
} catch {
// Keep token-derived identity if profile sync fails.
if (!cancelled) {
// 请求失败时重置,允许下次挂载时重试
syncedTokenRef.current = null;
}
}
};
@@ -177,7 +192,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
};
}, [state.token, state.user]);
}, [state.token]);
const value = useMemo<AuthContextType>(() => {
const loadingPhases: AuthPhase[] = [

View File

@@ -12,12 +12,11 @@ interface LanguageContextType {
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// 初始化语言:优先使用 localStorage否则使用浏览器语言作为临时值
// 初始化语言:优先使用 localStorage否则默认英文,后续由 IP 检测决定
const [language, setLanguageState] = useState<Language>(() => {
const saved = localStorage.getItem('language');
if (saved === 'en' || saved === 'zh') return saved;
// 临时使用浏览器语言后续会被IP检测覆盖如果没有保存的语言
return navigator.language.startsWith('zh') ? 'zh' : 'en';
return 'en';
});
// 检测IP地理位置并设置语言仅在首次加载且没有保存的语言时
@@ -30,16 +29,14 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil
return;
}
// 异步检测IP并设置语言
// 异步检测IP并设置语言:中国 IP 显示中文,其余显示英文
detectLanguageByIP()
.then((detectedLang) => {
setLanguageState(detectedLang);
updatePageMeta(detectedLang); // Update meta tags after detection
// 注意:这里不保存到 localStorage让用户首次访问时使用IP检测的结果
// 如果用户手动切换语言,才会保存到 localStorage
})
.catch((error) => {
// IP检测失败时保持使用浏览器语言检测的结果
// IP检测失败时保持英文默认值
console.warn('Failed to detect language by IP:', error);
updatePageMeta(language); // Update with fallback language
});

View File

@@ -0,0 +1,21 @@
import { useEffect } from 'react';
export function useScrollReveal() {
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('visible');
observer.unobserve(e.target);
}
});
},
{ threshold: 0.12, rootMargin: '0px 0px -40px 0px' }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
}

View File

@@ -2,6 +2,112 @@
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Warm Coral + Ink palette */
--color-primary: #e05a33;
--color-primary-hover: #c94a28;
--color-primary-light: #fef0ec;
--color-primary-glow: rgba(224, 90, 51, 0.15);
--color-secondary: #2a9d8f;
--color-secondary-light: #eef9f7;
--color-accent: #e9c46a;
--color-accent-light: #fdf6e3;
--color-bg: #faf8f4;
--color-surface: #ffffff;
--color-surface-raised: #f5f3ee;
--color-ink: #1c1917;
--color-ink-secondary: #57534e;
--color-ink-muted: #a8a29e;
--color-border: #e7e5e4;
--color-border-light: #f0eeea;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'DM Sans', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--color-ink);
background-color: var(--color-bg);
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
}
}
@layer components {
/* Distinctive button styles */
.btn-primary {
@apply inline-flex items-center gap-2 font-semibold rounded-xl transition-all duration-200;
background-color: var(--color-primary);
color: white;
box-shadow: 0 4px 14px var(--color-primary-glow), 0 1px 3px rgba(0,0,0,0.08);
}
.btn-primary:hover {
background-color: var(--color-primary-hover);
box-shadow: 0 6px 20px var(--color-primary-glow), 0 2px 4px rgba(0,0,0,0.1);
transform: translateY(-1px);
}
.btn-secondary {
@apply inline-flex items-center gap-2 font-semibold rounded-xl transition-all duration-200;
background-color: var(--color-surface);
color: var(--color-ink);
border: 1.5px solid var(--color-border);
}
.btn-secondary:hover {
border-color: var(--color-ink-muted);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
transform: translateY(-1px);
}
/* Card with warm shadow */
.card {
background-color: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.02);
transition: all 0.25s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.03);
transform: translateY(-2px);
}
/* Section spacing */
.section-padding {
@apply py-24 lg:py-32 px-6;
}
/* Grain texture overlay */
.grain::after {
content: '';
position: absolute;
inset: 0;
opacity: 0.3;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
}
/* Prose overrides for blog/docs */
.prose-warm {
--tw-prose-body: var(--color-ink-secondary);
--tw-prose-headings: var(--color-ink);
--tw-prose-links: var(--color-primary);
--tw-prose-bold: var(--color-ink);
--tw-prose-code: var(--color-primary);
}
}
@layer utilities {
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
@@ -13,15 +119,34 @@
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.3);
background-color: rgba(168, 162, 158, 0.3);
border-radius: 20px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.5);
background-color: rgba(168, 162, 158, 0.5);
}
/* Fade-in animation */
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
body {
@apply antialiased text-gray-900 bg-gray-50;
.animate-fade-up {
animation: fade-up 0.6s ease-out forwards;
}
/* Stagger delays */
.delay-100 { animation-delay: 100ms; }
.delay-200 { animation-delay: 200ms; }
.delay-300 { animation-delay: 300ms; }
.delay-400 { animation-delay: 400ms; }
.delay-500 { animation-delay: 500ms; }
}

View File

@@ -10,6 +10,7 @@ import type {
GoogleOAuthCallbackRequest,
LoginRequest,
RegisterRequest,
SendEmailCodeRequest,
UserInfoData,
UserInfo,
} from '../types/api';
@@ -66,6 +67,10 @@ export const authService = {
return buildSession(response.data, credentials.email);
},
async sendEmailCode(email: string): Promise<void> {
await http.post<null>('/user/email/code', { email } satisfies SendEmailCodeRequest, { skipAuth: true });
},
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });

33
src/lib/content.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { Language } from './translations';
export interface ContentMeta {
slug: string;
title: string;
description: string;
date: string;
tags: string[];
order?: number;
cover?: string;
}
export interface ContentManifest {
en: ContentMeta[];
zh: ContentMeta[];
}
export interface ContentItem {
meta: ContentMeta;
html: string;
}
const BASE = '/content';
export async function loadManifest(type: 'docs' | 'blog'): Promise<ContentManifest> {
const res = await fetch(`${BASE}/${type}-manifest.json`);
return res.json();
}
export async function loadContent(type: 'docs' | 'blog', lang: Language, slug: string): Promise<ContentItem> {
const res = await fetch(`${BASE}/${type}/${lang}/${slug}.json`);
return res.json();
}

View File

@@ -47,24 +47,11 @@ export async function detectCountryByIP(): Promise<string | null> {
/**
* 根据国家代码判断应该使用的语言
*
* @param countryCode 国家代码(如 'CN', 'TW', 'HK', 'SG' 等
* @param countryCode 国家代码(如 'CN', 'US'
* @returns 'zh' | 'en' 推荐的语言
*/
export function getLanguageByCountry(countryCode: string | null): 'zh' | 'en' {
if (!countryCode) {
return 'en';
}
// 中文地区列表
const chineseRegions = [
'CN', // 中国大陆
'TW', // 台湾
'HK', // 香港
'MO', // 澳门
'SG', // 新加坡(主要使用中文)
];
return chineseRegions.includes(countryCode) ? 'zh' : 'en';
return countryCode === 'CN' ? 'zh' : 'en';
}
/**

View File

@@ -8,14 +8,14 @@ interface SEOContent {
const seoContent: Record<Language, SEOContent> = {
zh: {
title: '⚡️ TexPixel - 公式识别工具',
description: '在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。',
keywords: '公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,混合文字识别,texpixel,TexPixel',
title: 'TexPixel - AI 数学公式识别工具 | LaTeX、MathML OCR',
description: '免费 AI 数学公式识别工具,支持手写和印刷体公式识别,一键将图片或 PDF 中的数学公式转换为 LaTeX、MathML 和 Markdown 格式。',
keywords: '数学公式识别,LaTeX OCR,手写公式识别,公式转LaTeX,数学OCR,MathML转换,手写方程识别,公式识别,数学公式OCR,texpixel',
},
en: {
title: '⚡️ TexPixel - Formula Recognition Tool',
description: 'Online formula recognition tool supporting printed and handwritten math formulas. Convert images to LaTeX, MathML, and Markdown quickly and accurately.',
keywords: 'formula recognition,math formula,OCR,handwriting recognition,latex,mathml,markdown,AI recognition,math tool,free,texpixel,TexPixel,document recognition',
title: 'TexPixel - AI Math Formula Recognition | LaTeX, MathML OCR Tool',
description: 'Free AI-powered math formula recognition tool. Convert handwritten or printed math formulas in images to LaTeX, MathML, and Markdown instantly. Supports PDF and image files.',
keywords: 'math formula recognition,LaTeX OCR,handwriting math recognition,formula to latex,math OCR,MathML converter,handwritten equation recognition,texpixel',
},
};

View File

@@ -35,6 +35,8 @@ export const translations = {
noHistory: 'No history records',
noMore: 'No more records',
historyHeader: 'History',
historyToggle: 'Show History',
historyLoginRequired: 'Login to enable history',
},
uploadModal: {
title: 'Upload File',
@@ -62,6 +64,9 @@ export const translations = {
noAccount: 'No account? Register',
continueWithGoogle: 'Google',
emailHint: 'Used only for sign-in and history sync.',
emailRequired: 'Please enter your email address.',
emailInvalid: 'Please enter a valid email address.',
passwordRequired: 'Please enter your password.',
passwordHint: 'Use at least 6 characters. Letters and numbers are recommended.',
confirmPassword: 'Confirm Password',
passwordMismatch: 'The two passwords do not match.',
@@ -69,6 +74,14 @@ export const translations = {
oauthExchanging: 'Completing Google sign-in...',
invalidOAuthState: 'Invalid OAuth state, please retry.',
oauthFailed: 'Google sign-in failed, please retry.',
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.',
},
export: {
title: 'Export',
@@ -102,7 +115,69 @@ export const translations = {
taskTimeout: 'Task timeout: Recognition took too long.',
networkError: 'Task timeout or network error.',
uploadFailed: 'Upload failed',
}
},
marketing: {
nav: {
home: 'Home',
docs: 'Docs',
blog: 'Blog',
pricing: 'Pricing',
launchApp: 'Launch App',
},
hero: {
title: 'Convert Math Formulas to LaTeX in Seconds',
subtitle: 'AI-powered OCR for handwritten and printed mathematical formulas. Get LaTeX, MathML, and Markdown output instantly.',
cta: 'Try It Free',
ctaSecondary: 'Learn More',
},
features: {
title: 'Features',
subtitle: 'Everything you need for formula recognition',
handwriting: 'Handwriting Recognition',
handwritingDesc: 'Accurately recognize handwritten math formulas from photos or scans',
multiFormat: 'Multi-Format Output',
multiFormatDesc: 'Export to LaTeX, MathML, Markdown, Word, and more',
pdf: 'PDF Support',
pdfDesc: 'Upload PDF documents and extract formulas automatically',
batch: 'Batch Processing',
batchDesc: 'Process multiple files at once for maximum efficiency',
accuracy: 'High Accuracy',
accuracyDesc: 'Powered by advanced AI models for industry-leading accuracy',
free: 'Free to Start',
freeDesc: 'Get started with free uploads, no credit card required',
},
howItWorks: {
title: 'How It Works',
step1: 'Upload',
step1Desc: 'Upload an image or PDF containing math formulas',
step2: 'Recognize',
step2Desc: 'Our AI analyzes and recognizes the formulas',
step3: 'Export',
step3Desc: 'Copy or export results in your preferred format',
},
pricing: {
title: 'Pricing',
subtitle: 'Choose the plan that fits your needs',
free: 'Free',
pro: 'Pro',
enterprise: 'Enterprise',
monthly: '/month',
custom: 'Custom',
getStarted: 'Get Started',
comingSoon: 'Coming Soon',
contactUs: 'Contact Us',
popular: 'Most Popular',
freeFeatures: ['3 uploads/day', 'LaTeX & Markdown output', 'Community support'],
proFeatures: ['Unlimited uploads', 'All export formats', 'Priority processing', 'API access'],
enterpriseFeatures: ['Custom volume', 'Dedicated support', 'SLA guarantee', 'On-premise option'],
},
footer: {
tagline: 'AI-powered math formula recognition',
product: 'Product',
resources: 'Resources',
contactTitle: 'Contact',
},
},
},
zh: {
common: {
@@ -140,6 +215,8 @@ export const translations = {
noHistory: '暂无历史记录',
noMore: '没有更多记录了',
historyHeader: '历史记录',
historyToggle: '显示历史',
historyLoginRequired: '登录后开启历史记录',
},
uploadModal: {
title: '上传文件',
@@ -167,6 +244,9 @@ export const translations = {
noAccount: '没有账号?去注册',
continueWithGoogle: 'Google',
emailHint: '仅用于登录和同步记录。',
emailRequired: '请输入邮箱地址。',
emailInvalid: '请输入有效的邮箱地址。',
passwordRequired: '请输入密码。',
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
confirmPassword: '确认密码',
passwordMismatch: '两次输入的密码不一致。',
@@ -174,6 +254,14 @@ export const translations = {
oauthExchanging: '正在完成 Google 登录...',
invalidOAuthState: 'OAuth 状态校验失败,请重试',
oauthFailed: 'Google 登录失败,请重试',
sendCode: '发送验证码',
resendCode: '重新发送',
codeSent: '验证码已发送',
verificationCode: '验证码',
verificationCodePlaceholder: '请输入 6 位验证码',
verificationCodeRequired: '请输入验证码。',
verificationCodeHint: '请查收邮箱中的 6 位验证码。',
sendCodeFailed: '发送验证码失败,请重试。',
},
export: {
title: '导出',
@@ -207,7 +295,69 @@ export const translations = {
taskTimeout: '任务超时:识别时间过长。',
networkError: '任务超时或网络错误。',
uploadFailed: '上传失败',
}
},
marketing: {
nav: {
home: '首页',
docs: '文档',
blog: '博客',
pricing: '价格',
launchApp: '启动应用',
},
hero: {
title: '数学公式秒级转换为 LaTeX',
subtitle: 'AI 驱动的手写和印刷体数学公式识别,即时输出 LaTeX、MathML 和 Markdown。',
cta: '免费试用',
ctaSecondary: '了解更多',
},
features: {
title: '功能特性',
subtitle: '公式识别所需的一切',
handwriting: '手写识别',
handwritingDesc: '精准识别照片或扫描件中的手写数学公式',
multiFormat: '多格式输出',
multiFormatDesc: '导出为 LaTeX、MathML、Markdown、Word 等格式',
pdf: 'PDF 支持',
pdfDesc: '上传 PDF 文档,自动提取公式',
batch: '批量处理',
batchDesc: '一次处理多个文件,效率最大化',
accuracy: '高精度',
accuracyDesc: '由先进 AI 模型驱动,行业领先的识别精度',
free: '免费开始',
freeDesc: '免费上传体验,无需信用卡',
},
howItWorks: {
title: '使用流程',
step1: '上传',
step1Desc: '上传包含数学公式的图片或 PDF',
step2: '识别',
step2Desc: '我们的 AI 分析并识别公式',
step3: '导出',
step3Desc: '复制或导出为你喜欢的格式',
},
pricing: {
title: '价格方案',
subtitle: '选择适合您的方案',
free: '免费版',
pro: '专业版',
enterprise: '企业版',
monthly: '/月',
custom: '定制',
getStarted: '开始使用',
comingSoon: '即将推出',
contactUs: '联系我们',
popular: '最受欢迎',
freeFeatures: ['每日 3 次上传', 'LaTeX 和 Markdown 输出', '社区支持'],
proFeatures: ['无限上传', '所有导出格式', '优先处理', 'API 访问'],
enterpriseFeatures: ['自定义用量', '专属支持', 'SLA 保障', '私有部署选项'],
},
footer: {
tagline: 'AI 驱动的数学公式识别',
product: '产品',
resources: '资源',
contactTitle: '联系方式',
},
},
}
};

View File

@@ -4,6 +4,7 @@ import './index.css';
import { AuthProvider } from './contexts/AuthContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { BrowserRouter } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import AppRouter from './routes/AppRouter';
// 错误处理:捕获未处理的错误
@@ -23,6 +24,7 @@ if (!rootElement) {
try {
createRoot(rootElement).render(
<StrictMode>
<HelmetProvider>
<BrowserRouter>
<AuthProvider>
<LanguageProvider>
@@ -30,6 +32,7 @@ try {
</LanguageProvider>
</AuthProvider>
</BrowserRouter>
</HelmetProvider>
</StrictMode>
);
} catch (error) {

168
src/pages/AboutPage.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import SEOHead from '../components/seo/SEOHead';
export default function AboutPage() {
const { language } = useLanguage();
const zh = language === 'zh';
return (
<>
<SEOHead
title={zh ? '关于我们 — TexPixel' : 'About — TexPixel'}
description={zh
? 'TexPixel 致力于让每位学生和研究者都能轻松将数学公式转换为 LaTeX。'
: 'TexPixel is on a mission to make math typesetting effortless for every student and researcher.'}
path="/about"
/>
<div className="docs-detail">
<Link to="/" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '返回首页' : 'Back to home'}
</Link>
<div className="docs-article-header">
<div className="docs-article-tags">
<span className="docs-tag">{zh ? '公司' : 'Company'}</span>
</div>
<h1 className="docs-article-h1">{zh ? '关于 TexPixel' : 'About TexPixel'}</h1>
<div className="docs-meta-row">
<span>{zh ? '让数学排版触手可及' : 'Making math typesetting effortless'}</span>
</div>
</div>
<div className="docs-prose">
{zh ? <AboutZh /> : <AboutEn />}
</div>
</div>
</>
);
}
function AboutEn() {
return (
<>
<h2>Our Mission</h2>
<p>
TexPixel exists to eliminate the most tedious part of academic writing: transcribing handwritten or printed
formulas into LaTeX. We believe that students, researchers, and educators should spend their time thinking
about ideas not fighting with syntax.
</p>
<p>
A photograph of a chalkboard, a scan of a textbook, a snapshot of your own handwriting TexPixel turns any
of these into clean, copy-paste-ready LaTeX, Markdown, or Word equations in under a second.
</p>
<h2>What We Build</h2>
<p>
Our core product is an AI-powered document recognition engine trained specifically on mathematical notation.
Unlike general-purpose OCR tools, TexPixel understands the structure of formulas fractions, integrals,
summations, matrices, and multi-line expressions and produces output that actually works the first time.
</p>
<p>We offer:</p>
<ul>
<li><strong>Web App</strong> instant recognition in the browser, no installation required.</li>
<li><strong>API (Beta)</strong> integrate formula recognition directly into your tools and workflows.</li>
<li><strong>Desktop App</strong> fully offline processing for privacy-sensitive documents.</li>
</ul>
<h2>Who Uses TexPixel</h2>
<p>
Our users range from undergraduate students digitizing lecture notes to researchers processing thousands of
equations from scanned papers. TexPixel is used in over 50 countries, with particular strength in China,
the United States, Germany, Japan, and India.
</p>
<h2>Our Principles</h2>
<ul>
<li><strong>Speed over friction.</strong> Every extra click is one too many. We optimize for the fastest possible path from image to output.</li>
<li><strong>Accuracy where it matters.</strong> A wrong minus sign or missing exponent can invalidate an entire equation. We hold our models to a high bar.</li>
<li><strong>Privacy by design.</strong> Your documents belong to you. We do not use uploaded content to train models without your explicit consent.</li>
<li><strong>Accessible pricing.</strong> Students should not have to pay enterprise prices. Our free tier is generous, and our paid plans are priced for individuals.</li>
</ul>
<h2>The Team</h2>
<p>
TexPixel is a small, focused team of engineers and researchers who care deeply about tools that make
scientific writing easier. We are based in China, with contributors around the world.
</p>
<p>
We're always looking for people who share our obsession with accuracy and clean user interfaces.
If that sounds like you, reach out at <a href="mailto:hello@texpixel.com">hello@texpixel.com</a>.
</p>
<h2>Get in Touch</h2>
<p>
For general inquiries: <a href="mailto:hello@texpixel.com">hello@texpixel.com</a><br />
For support: <a href="mailto:support@texpixel.com">support@texpixel.com</a><br />
For legal and privacy matters: <a href="mailto:legal@texpixel.com">legal@texpixel.com</a>
</p>
<p>
<Link to="/contact">Send us a message </Link>
</p>
</>
);
}
function AboutZh() {
return (
<>
<h2>使</h2>
<p>
TexPixel LaTeX
</p>
<p>
TexPixel
使 LaTeXMarkdown Word
</p>
<h2></h2>
<p>
AI OCR
TexPixel
</p>
<p></p>
<ul>
<li><strong>Web </strong></li>
<li><strong>APIBeta</strong></li>
<li><strong></strong>线</li>
</ul>
<h2>使 TexPixel</h2>
<p>
TexPixel 50 广使
</p>
<h2></h2>
<ul>
<li><strong></strong></li>
<li><strong></strong></li>
<li><strong></strong>使</li>
<li><strong></strong></li>
</ul>
<h2></h2>
<p>
TexPixel 便
</p>
<p>
<a href="mailto:hello@texpixel.com">hello@texpixel.com</a>
</p>
<h2></h2>
<p>
<a href="mailto:hello@texpixel.com">hello@texpixel.com</a><br />
<a href="mailto:support@texpixel.com">support@texpixel.com</a><br />
<a href="mailto:legal@texpixel.com">legal@texpixel.com</a>
</p>
<p>
<Link to="/contact"> </Link>
</p>
</>
);
}

View File

@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
import { loadContent, type ContentItem } from '../lib/content';
function estimateReadTime(html: string): number {
const text = html.replace(/<[^>]+>/g, '');
const words = text.split(/\s+/).filter(Boolean).length;
return Math.max(2, Math.round(words / 200));
}
function formatDate(dateStr: string, lang: string): string {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export default function BlogDetailPage() {
const { slug } = useParams<{ slug: string }>();
const { language } = useLanguage();
const [content, setContent] = useState<ContentItem | null>(null);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
setContent(null);
setNotFound(false);
if (slug) {
loadContent('blog', language, slug)
.then(setContent)
.catch(() => setNotFound(true));
}
}, [slug, language]);
const zh = language === 'zh';
if (notFound) {
return (
<div className="docs-detail">
<Link to="/blog" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文章' : 'All posts'}
</Link>
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-muted)' }}>
<p style={{ fontSize: '18px', marginBottom: '8px' }}>
{zh ? '文章未找到' : 'Post not found'}
</p>
<Link to="/blog" style={{ color: 'var(--primary)', fontSize: '14px' }}>
{zh ? '返回博客 →' : 'Back to blog →'}
</Link>
</div>
</div>
);
}
if (!content) {
return (
<div className="docs-skeleton-wrap">
<div className="skeleton-line" style={{ width: '80px', height: '14px', marginBottom: '44px' }} />
<div className="skeleton-line" style={{ width: '60%', height: '44px', marginBottom: '16px' }} />
<div className="skeleton-line" style={{ width: '40%', height: '16px', marginBottom: '48px' }} />
<div className="skeleton-line" style={{ width: '100%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '92%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '96%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '80%', height: '16px' }} />
</div>
);
}
const readTime = estimateReadTime(content.html);
return (
<>
<SEOHead
title={content.meta.title}
description={content.meta.description}
path={`/blog/${slug}`}
type="article"
publishedTime={content.meta.date}
/>
<div className="docs-detail">
{/* Back */}
<Link to="/blog" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文章' : 'All posts'}
</Link>
{/* Article header */}
<div className="docs-article-header">
{content.meta.tags.length > 0 && (
<div className="docs-article-tags">
{content.meta.tags.map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
</div>
)}
<h1 className="docs-article-h1">{content.meta.title}</h1>
<div className="docs-meta-row">
<span>{formatDate(content.meta.date, language)}</span>
<span className="docs-meta-sep">·</span>
<span>{readTime} {zh ? '分钟阅读' : 'min read'}</span>
</div>
</div>
{/* Article body */}
<div
className="docs-prose"
dangerouslySetInnerHTML={{ __html: content.html }}
/>
{/* CTA */}
<div className="docs-cta-box">
<div className="docs-cta-title">
{zh ? '准备好试试了吗?' : 'Ready to try it yourself?'}
</div>
<p className="docs-cta-desc">
{zh
? '上传一张公式图片,秒级获得 LaTeX 输出——无需注册。'
: 'Upload a formula image and get LaTeX output in under a second — no sign-up needed.'}
</p>
<Link to="/app" className="btn btn-primary" style={{ display: 'inline-flex' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M5 3l14 9-14 9V3z" />
</svg>
{zh ? '免费试用 TexPixel' : 'Try TexPixel Free'}
</Link>
</div>
</div>
</>
);
}

112
src/pages/BlogListPage.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
import { loadManifest, type ContentMeta } from '../lib/content';
import { useScrollReveal } from '../hooks/useScrollReveal';
function formatDate(dateStr: string, lang: string): string {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export default function BlogListPage() {
const { language } = useLanguage();
const [posts, setPosts] = useState<ContentMeta[]>([]);
useEffect(() => {
loadManifest('blog').then(manifest => {
setPosts(manifest[language] || []);
});
}, [language]);
useScrollReveal();
const zh = language === 'zh';
const featured = posts[0];
const rest = posts.slice(1);
return (
<>
<SEOHead
title={zh ? 'TexPixel 博客' : 'TexPixel Blog'}
description={zh ? '关于公式识别和 LaTeX 的更新、教程与见解。' : 'Updates, tutorials, and insights on formula recognition and LaTeX.'}
path="/blog"
/>
<div className="blog-page">
{/* Header */}
<div className="blog-page-header reveal">
<div className="eyebrow">{zh ? '博客' : 'Blog'}</div>
<h1 className="blog-page-title">
{zh ? '最新文章' : 'Latest Posts'}
</h1>
<p className="blog-page-subtitle">
{zh
? '关于公式识别和 LaTeX 的更新、教程与见解。'
: 'Updates, tutorials, and insights on formula recognition and LaTeX.'}
</p>
</div>
{/* Featured post */}
{featured && (
<Link to={`/blog/${featured.slug}`} className="blog-featured reveal">
<div className="blog-featured-eyebrow">
{zh ? '精选文章' : 'Featured'}
</div>
<div className="blog-featured-title">{featured.title}</div>
<div className="blog-featured-desc">{featured.description}</div>
<div className="blog-featured-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
{featured.tags.slice(0, 3).map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{formatDate(featured.date, language)}
</span>
</div>
<span className="blog-featured-read">
{zh ? '阅读全文' : 'Read more'}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</span>
</div>
</Link>
)}
{/* Post grid */}
{rest.length > 0 && (
<div className="blog-grid">
{rest.map(post => (
<Link key={post.slug} to={`/blog/${post.slug}`} className="blog-card reveal">
<div className="blog-card-date">{formatDate(post.date, language)}</div>
<div className="blog-card-title">{post.title}</div>
<div className="blog-card-desc">{post.description}</div>
{post.tags.length > 0 && (
<div className="blog-card-footer">
{post.tags.slice(0, 2).map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
</div>
)}
</Link>
))}
</div>
)}
{/* Empty state */}
{posts.length === 0 && (
<div className="blog-empty">
{zh ? '暂无文章,敬请期待。' : 'No posts yet. Check back soon.'}
</div>
)}
</div>
</>
);
}

185
src/pages/ContactPage.tsx Normal file
View File

@@ -0,0 +1,185 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import SEOHead from '../components/seo/SEOHead';
export default function ContactPage() {
const { language } = useLanguage();
const zh = language === 'zh';
return (
<>
<SEOHead
title={zh ? '联系我们 — TexPixel' : 'Contact — TexPixel'}
description={zh
? '通过邮件联系 TexPixel 团队,获取支持、商务合作或反馈建议。'
: 'Get in touch with the TexPixel team for support, partnerships, or feedback.'}
path="/contact"
/>
<div className="docs-detail">
<Link to="/" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '返回首页' : 'Back to home'}
</Link>
<div className="docs-article-header">
<div className="docs-article-tags">
<span className="docs-tag">{zh ? '公司' : 'Company'}</span>
</div>
<h1 className="docs-article-h1">{zh ? '联系我们' : 'Contact Us'}</h1>
<div className="docs-meta-row">
<span>{zh ? '我们通常在 1 个工作日内回复' : 'We typically reply within 1 business day'}</span>
</div>
</div>
<div className="docs-prose">
{zh ? <ContactZh /> : <ContactEn />}
</div>
</div>
</>
);
}
function ContactEn() {
return (
<>
<p>
Have a question, a bug to report, or an idea to share? We'd love to hear from you.
Choose the right channel below and we'll get back to you as quickly as we can.
</p>
<h2>Support</h2>
<p>
For issues with recognition quality, account problems, credit purchases, or any technical questions about
the product:
</p>
<p>
<a href="mailto:support@texpixel.com">support@texpixel.com</a>
</p>
<p>
When writing in, please include:
</p>
<ul>
<li>A brief description of the issue</li>
<li>The task ID (shown in your history panel) if the issue is recognition-related</li>
<li>Your browser and operating system (for web app issues)</li>
</ul>
<h2>Billing & Credits</h2>
<p>
For questions about charges, credit balance discrepancies, or refund requests:
</p>
<p>
<a href="mailto:billing@texpixel.com">billing@texpixel.com</a>
</p>
<p>
Please include your account email and a description of the issue. For potential billing errors,
include the transaction date and amount.
</p>
<h2>Partnerships & API Access</h2>
<p>
Interested in integrating TexPixel into your platform, LMS, or research pipeline? Looking for volume
pricing or a custom agreement?
</p>
<p>
<a href="mailto:partnerships@texpixel.com">partnerships@texpixel.com</a>
</p>
<h2>Legal & Privacy</h2>
<p>
For privacy requests (data access, deletion, correction), legal inquiries, or DMCA notices:
</p>
<p>
<a href="mailto:legal@texpixel.com">legal@texpixel.com</a>
</p>
<h2>General Inquiries</h2>
<p>
For everything else press, feedback, or just saying hello:
</p>
<p>
<a href="mailto:hello@texpixel.com">hello@texpixel.com</a>
</p>
<h2>Response Times</h2>
<p>
We aim to respond to all inquiries within 1 business day (China Standard Time, UTC+8).
During periods of high volume, support requests may take up to 3 business days.
</p>
<p>
For self-service help, check our <Link to="/docs">documentation</Link> most common questions are
answered there.
</p>
</>
);
}
function ContactZh() {
return (
<>
<p>
Bug
</p>
<h2></h2>
<p>
</p>
<p>
<a href="mailto:support@texpixel.com">support@texpixel.com</a>
</p>
<p>便</p>
<ul>
<li></li>
<li> ID</li>
<li> Web </li>
</ul>
<h2></h2>
<p>
退
</p>
<p>
<a href="mailto:billing@texpixel.com">billing@texpixel.com</a>
</p>
<p>
</p>
<h2> API </h2>
<p>
TexPixel
</p>
<p>
<a href="mailto:partnerships@texpixel.com">partnerships@texpixel.com</a>
</p>
<h2></h2>
<p>
访 DMCA
</p>
<p>
<a href="mailto:legal@texpixel.com">legal@texpixel.com</a>
</p>
<h2></h2>
<p>
访
</p>
<p>
<a href="mailto:hello@texpixel.com">hello@texpixel.com</a>
</p>
<h2></h2>
<p>
1 UTC+8
3
</p>
<p>
<Link to="/docs"></Link>
</p>
</>
);
}

View File

@@ -0,0 +1,304 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import SEOHead from '../components/seo/SEOHead';
const EFFECTIVE_DATE_EN = 'March 26, 2026';
const EFFECTIVE_DATE_ZH = '2026年3月26日';
export default function CookiePolicyPage() {
const { language } = useLanguage();
const zh = language === 'zh';
return (
<>
<SEOHead
title={zh ? 'Cookie 政策 — TexPixel' : 'Cookie Policy — TexPixel'}
description={zh
? 'TexPixel 如何使用 Cookie 及本地存储技术。'
: 'How TexPixel uses cookies and local storage technologies.'}
path="/cookies"
/>
<div className="docs-detail">
<Link to="/" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '返回首页' : 'Back to home'}
</Link>
<div className="docs-article-header">
<div className="docs-article-tags">
<span className="docs-tag">Legal</span>
</div>
<h1 className="docs-article-h1">{zh ? 'Cookie 政策' : 'Cookie Policy'}</h1>
<div className="docs-meta-row">
<span>{zh ? `生效日期:${EFFECTIVE_DATE_ZH}` : `Effective: ${EFFECTIVE_DATE_EN}`}</span>
<span className="docs-meta-sep">·</span>
<span>TexPixel</span>
</div>
</div>
<div className="docs-prose">
{zh ? <CookieZh /> : <CookieEn />}
</div>
</div>
</>
);
}
function CookieEn() {
return (
<>
<p>
This Cookie Policy explains how TexPixel ("we", "us", or "our") uses cookies and similar technologies on our
website and web application. By using the Service, you consent to the use of cookies as described here.
</p>
<h2>1. What Are Cookies?</h2>
<p>
Cookies are small text files placed on your device by websites you visit. They are widely used to make websites
work efficiently and to provide information to site owners. Beyond traditional cookies, we also use browser
<strong> localStorage</strong> a similar technology that stores data in your browser without an expiration date.
</p>
<h2>2. Cookies and Storage We Use</h2>
<p>We use the following types of storage technologies:</p>
<h3 style={{ fontSize: '18px', fontWeight: 600, marginTop: '28px', marginBottom: '10px' }}>
Strictly Necessary
</h3>
<p>
These are essential for the Service to function. They cannot be disabled without breaking core functionality.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px', marginBottom: '8px' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}>Name</th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}>Type</th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}>Purpose</th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}>Retention</th>
</tr>
</thead>
<tbody>
<tr style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>texpixel_token</td>
<td style={{ padding: '8px 12px' }}>localStorage</td>
<td style={{ padding: '8px 12px' }}>Stores your JWT authentication token to keep you logged in</td>
<td style={{ padding: '8px 12px' }}>Until sign-out or token expiry</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>language</td>
<td style={{ padding: '8px 12px' }}>localStorage</td>
<td style={{ padding: '8px 12px' }}>Remembers your language preference (en/zh)</td>
<td style={{ padding: '8px 12px' }}>Persistent (until cleared)</td>
</tr>
<tr>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>texpixel_guest_usage_count</td>
<td style={{ padding: '8px 12px' }}>localStorage</td>
<td style={{ padding: '8px 12px' }}>Tracks free-tier usage count for guest (unauthenticated) users</td>
<td style={{ padding: '8px 12px' }}>Persistent (until cleared)</td>
</tr>
</tbody>
</table>
<h3 style={{ fontSize: '18px', fontWeight: 600, marginTop: '28px', marginBottom: '10px' }}>
Functional
</h3>
<p>
These enhance your experience but are not strictly required. Disabling them may affect some features.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px', marginBottom: '8px' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}>Name</th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}>Type</th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}>Purpose</th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}>Retention</th>
</tr>
</thead>
<tbody>
<tr>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>_ga, _gid</td>
<td style={{ padding: '8px 12px' }}>Cookie</td>
<td style={{ padding: '8px 12px' }}>Analytics (if enabled): distinguishes users and sessions for aggregate usage statistics</td>
<td style={{ padding: '8px 12px' }}>2 years / 24 hours</td>
</tr>
</tbody>
</table>
<h2>3. Third-Party Cookies</h2>
<p>
When you use <strong>Sign in with Google</strong>, Google may set its own cookies as part of the OAuth
authentication flow. These cookies are governed by{' '}
<a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">Google's Privacy Policy</a>.
We do not control Google's cookies and cannot disable them on Google's behalf.
</p>
<p>
Our payment processor may also set session cookies during the checkout process. These are strictly necessary
for completing your purchase and are removed after the transaction.
</p>
<h2>4. What We Do Not Do</h2>
<ul>
<li>We do not use cookies or localStorage to track you across third-party websites.</li>
<li>We do not sell data derived from cookies to advertisers or data brokers.</li>
<li>We do not serve targeted advertising cookies.</li>
</ul>
<h2>5. Managing Cookies & Local Storage</h2>
<p>
You can control and delete cookies and localStorage data through your browser settings. Note that clearing
your authentication token (<code>texpixel_token</code>) will sign you out. Clearing the <code>language</code>{' '}
key will reset your language preference to auto-detection.
</p>
<p>Instructions for managing storage in common browsers:</p>
<ul>
<li><strong>Chrome:</strong> Settings → Privacy and security → Clear browsing data</li>
<li><strong>Firefox:</strong> Settings → Privacy & Security → Cookies and Site Data → Clear Data</li>
<li><strong>Safari:</strong> Settings → Privacy → Manage Website Data</li>
<li><strong>Edge:</strong> Settings → Privacy, search, and services → Clear browsing data</li>
</ul>
<p>
To inspect or manually delete our localStorage entries, open your browser's Developer Tools Application
tab Local Storage <code>texpixel.com</code>.
</p>
<h2>6. Changes to This Policy</h2>
<p>
We may update this Cookie Policy from time to time. The "Effective" date at the top of this page indicates
when the policy was last revised. Continued use of the Service after changes constitutes acceptance.
</p>
<h2>7. Contact Us</h2>
<p>
If you have questions about our use of cookies, contact us at{' '}
<a href="mailto:privacy@texpixel.com">privacy@texpixel.com</a>.
</p>
</>
);
}
function CookieZh() {
return (
<>
<p>
Cookie TexPixel"我们" Web 使 Cookie
使使 Cookie
</p>
<h2>1. Cookie</h2>
<p>
Cookie 访广使
Cookie 使
<strong> localStorage</strong>
</p>
<h2>2. 使 Cookie </h2>
<p>使</p>
<h3 style={{ fontSize: '18px', fontWeight: 600, marginTop: '28px', marginBottom: '10px' }}>
</h3>
<p>使</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px', marginBottom: '8px' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}></th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}></th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}></th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}></th>
</tr>
</thead>
<tbody>
<tr style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>texpixel_token</td>
<td style={{ padding: '8px 12px' }}>localStorage</td>
<td style={{ padding: '8px 12px' }}> JWT </td>
<td style={{ padding: '8px 12px' }}>退</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>language</td>
<td style={{ padding: '8px 12px' }}>localStorage</td>
<td style={{ padding: '8px 12px' }}>en/zh</td>
<td style={{ padding: '8px 12px' }}></td>
</tr>
<tr>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>texpixel_guest_usage_count</td>
<td style={{ padding: '8px 12px' }}>localStorage</td>
<td style={{ padding: '8px 12px' }}>访使</td>
<td style={{ padding: '8px 12px' }}></td>
</tr>
</tbody>
</table>
<h3 style={{ fontSize: '18px', fontWeight: 600, marginTop: '28px', marginBottom: '10px' }}>
</h3>
<p>使</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px', marginBottom: '8px' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}></th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}></th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}></th>
<th style={{ textAlign: 'left', padding: '8px 12px', fontWeight: 600 }}></th>
</tr>
</thead>
<tbody>
<tr>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>_ga, _gid</td>
<td style={{ padding: '8px 12px' }}>Cookie</td>
<td style={{ padding: '8px 12px' }}>使</td>
<td style={{ padding: '8px 12px' }}>2 / 24</td>
</tr>
</tbody>
</table>
<h2>3. Cookie</h2>
<p>
使<strong></strong>Google OAuth Cookie
Cookie {' '}
<a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">Google </a>
Google Cookie Google
</p>
<p>
Cookie Cookie
</p>
<h2>4. </h2>
<ul>
<li>使 Cookie localStorage </li>
<li> Cookie 广</li>
<li>广 Cookie</li>
</ul>
<h2>5. Cookie </h2>
<p>
Cookie localStorage
<code>texpixel_token</code>退 <code>language</code>
</p>
<p></p>
<ul>
<li><strong>Chrome</strong> </li>
<li><strong>Firefox</strong> Cookie </li>
<li><strong>Safari</strong> </li>
<li><strong>Edge</strong> </li>
</ul>
<p>
localStorage Application
Local Storage <code>texpixel.com</code>
</p>
<h2>6. </h2>
<p>
Cookie "生效日期"
使
</p>
<h2>7. </h2>
<p>
使 Cookie {' '}
<a href="mailto:privacy@texpixel.com">privacy@texpixel.com</a>
</p>
</>
);
}

134
src/pages/DocDetailPage.tsx Normal file
View File

@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
import { loadContent, type ContentItem } from '../lib/content';
function estimateReadTime(html: string): number {
const text = html.replace(/<[^>]+>/g, '');
const words = text.split(/\s+/).filter(Boolean).length;
return Math.max(2, Math.round(words / 200));
}
function formatDate(dateStr: string, lang: string): string {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export default function DocDetailPage() {
const { slug } = useParams<{ slug: string }>();
const { language } = useLanguage();
const [content, setContent] = useState<ContentItem | null>(null);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
setContent(null);
setNotFound(false);
if (slug) {
loadContent('docs', language, slug)
.then(setContent)
.catch(() => setNotFound(true));
}
}, [slug, language]);
const zh = language === 'zh';
if (notFound) {
return (
<div className="docs-detail">
<Link to="/docs" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文档' : 'All docs'}
</Link>
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-muted)' }}>
<p style={{ fontSize: '18px', marginBottom: '8px' }}>
{zh ? '文档未找到' : 'Doc not found'}
</p>
<Link to="/docs" style={{ color: 'var(--primary)', fontSize: '14px' }}>
{zh ? '返回文档中心 →' : 'Back to docs →'}
</Link>
</div>
</div>
);
}
if (!content) {
return (
<div className="docs-skeleton-wrap">
<div className="skeleton-line" style={{ width: '80px', height: '14px', marginBottom: '44px' }} />
<div className="skeleton-line" style={{ width: '60%', height: '44px', marginBottom: '16px' }} />
<div className="skeleton-line" style={{ width: '40%', height: '16px', marginBottom: '48px' }} />
<div className="skeleton-line" style={{ width: '100%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '92%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '96%', height: '16px' }} />
<div className="skeleton-line" style={{ width: '80%', height: '16px' }} />
</div>
);
}
const readTime = estimateReadTime(content.html);
return (
<>
<SEOHead
title={content.meta.title}
description={content.meta.description}
path={`/docs/${slug}`}
/>
<div className="docs-detail">
{/* Back */}
<Link to="/docs" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '所有文档' : 'All docs'}
</Link>
{/* Article header */}
<div className="docs-article-header">
{content.meta.tags.length > 0 && (
<div className="docs-article-tags">
{content.meta.tags.map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
</div>
)}
<h1 className="docs-article-h1">{content.meta.title}</h1>
<div className="docs-meta-row">
<span>{formatDate(content.meta.date, language)}</span>
<span className="docs-meta-sep">·</span>
<span>{readTime} {zh ? '分钟阅读' : 'min read'}</span>
</div>
</div>
{/* Article body */}
<div
className="docs-prose"
dangerouslySetInnerHTML={{ __html: content.html }}
/>
{/* CTA */}
<div className="docs-cta-box">
<div className="docs-cta-title">
{zh ? '准备好试试了吗?' : 'Ready to try it yourself?'}
</div>
<p className="docs-cta-desc">
{zh
? '上传一张公式图片,秒级获得 LaTeX 输出——无需注册。'
: 'Upload a formula image and get LaTeX output in under a second — no sign-up needed.'}
</p>
<Link to="/app" className="btn btn-primary" style={{ display: 'inline-flex' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M5 3l14 9-14 9V3z" />
</svg>
{zh ? '免费试用 TexPixel' : 'Try TexPixel Free'}
</Link>
</div>
</div>
</>
);
}

137
src/pages/DocsListPage.tsx Normal file
View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import SEOHead from '../components/seo/SEOHead';
import { useLanguage } from '../contexts/LanguageContext';
import { loadManifest, type ContentMeta } from '../lib/content';
function estimateReadTime(desc: string): number {
return Math.max(2, Math.round(desc.split(/\s+/).length / 3));
}
const DOC_ICONS: Record<string, JSX.Element> = {
'getting-started': (
<>
<path d="M5 3l14 9-14 9V3z" />
</>
),
'image-to-latex': (
<>
<rect x="3" y="3" width="18" height="14" rx="2" />
<path d="M3 9h18" />
<path d="M9 21l3-3 3 3" />
</>
),
'supported-formats': (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="M8 13h8M8 17h5" />
</>
),
'copy-to-word': (
<>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M8 9h8M8 12h6M8 15h4" />
</>
),
'ocr-accuracy': (
<>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</>
),
'pdf-extraction': (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6M10 12l2 2 4-4" />
</>
),
faq: (
<>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</>
),
};
function DocIcon({ slug }: { slug: string }) {
const paths = DOC_ICONS[slug] ?? DOC_ICONS['supported-formats'];
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
{paths}
</svg>
);
}
export default function DocsListPage() {
const { language } = useLanguage();
const [docs, setDocs] = useState<ContentMeta[]>([]);
useEffect(() => {
loadManifest('docs').then(manifest => {
setDocs(manifest[language] || []);
});
}, [language]);
const zh = language === 'zh';
return (
<>
<SEOHead
title={zh ? 'TexPixel 文档中心' : 'TexPixel Documentation'}
description={zh ? '公式识别入门指南、格式说明与常见问题。' : 'Guides, format references, and answers for getting the most out of TexPixel.'}
path="/docs"
/>
<div className="docs-page">
{/* Header */}
<div className="docs-page-header">
<div className="eyebrow">{zh ? '文档中心' : 'Documentation'}</div>
<h1 className="docs-page-title">
{zh ? '学习 TexPixel' : 'Learn TexPixel'}
</h1>
<p className="docs-page-subtitle">
{zh
? '公式识别入门所需的一切——上传技巧、格式说明与常见问题。'
: 'Everything you need to get the most out of formula recognition — upload tips, format guides, and FAQs.'}
</p>
</div>
{/* List */}
<div className="docs-list">
{docs.map((doc) => (
<Link key={doc.slug} to={`/docs/${doc.slug}`} className="docs-article-card">
<div className="docs-article-card-inner">
<div className="docs-article-icon">
<DocIcon slug={doc.slug} />
</div>
<div className="docs-article-body">
<div className="docs-article-title">{doc.title}</div>
<div className="docs-article-desc">{doc.description}</div>
<div className="docs-article-meta">
{doc.tags.slice(0, 2).map(tag => (
<span key={tag} className="docs-tag">{tag}</span>
))}
<span className="docs-read-time">
{estimateReadTime(doc.description)} {zh ? '分钟阅读' : 'min read'}
</span>
</div>
</div>
<svg className="docs-article-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</div>
</Link>
))}
{docs.length === 0 && (
<div className="docs-empty">
{zh ? '文档即将发布。' : 'Documentation coming soon.'}
</div>
)}
</div>
</div>
</>
);
}

32
src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,32 @@
import SEOHead from '../components/seo/SEOHead';
import HeroSection from '../components/home/HeroSection';
import ProductSuiteSection from '../components/home/ProductSuiteSection';
import FeaturesSection from '../components/home/FeaturesSection';
import ShowcaseSection from '../components/home/ShowcaseSection';
import TestimonialsSection from '../components/home/TestimonialsSection';
import PricingSection from '../components/home/PricingSection';
import DocsSeoSection from '../components/home/DocsSeoSection';
import { useScrollReveal } from '../hooks/useScrollReveal';
import { useLanguage } from '../contexts/LanguageContext';
export default function HomePage() {
const { t } = useLanguage();
useScrollReveal();
return (
<>
<SEOHead
title="TexPixel - AI Math Formula Recognition | LaTeX, MathML OCR Tool"
description={t.marketing.hero.subtitle}
path="/"
/>
<HeroSection />
<ProductSuiteSection />
<FeaturesSection />
<ShowcaseSection />
<TestimonialsSection />
<PricingSection />
<DocsSeoSection />
</>
);
}

283
src/pages/PrivacyPage.tsx Normal file
View File

@@ -0,0 +1,283 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import SEOHead from '../components/seo/SEOHead';
const EFFECTIVE_DATE_EN = 'March 26, 2026';
const EFFECTIVE_DATE_ZH = '2026年3月26日';
export default function PrivacyPage() {
const { language } = useLanguage();
const zh = language === 'zh';
return (
<>
<SEOHead
title={zh ? '隐私政策 — TexPixel' : 'Privacy Policy — TexPixel'}
description={zh
? 'TexPixel 如何收集、使用和保护您的个人信息。'
: 'How TexPixel collects, uses, and protects your personal information.'}
path="/privacy"
/>
<div className="docs-detail">
<Link to="/" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '返回首页' : 'Back to home'}
</Link>
<div className="docs-article-header">
<div className="docs-article-tags">
<span className="docs-tag">Legal</span>
</div>
<h1 className="docs-article-h1">{zh ? '隐私政策' : 'Privacy Policy'}</h1>
<div className="docs-meta-row">
<span>{zh ? `生效日期:${EFFECTIVE_DATE_ZH}` : `Effective: ${EFFECTIVE_DATE_EN}`}</span>
<span className="docs-meta-sep">·</span>
<span>TexPixel</span>
</div>
</div>
<div className="docs-prose">
{zh ? <PrivacyZh /> : <PrivacyEn />}
</div>
</div>
</>
);
}
function PrivacyEn() {
return (
<>
<p>
TexPixel ("we", "us", or "our") is committed to protecting your privacy. This Privacy Policy explains what
information we collect, how we use it, and your rights regarding your data. By using the Service, you consent
to the practices described here.
</p>
<h2>1. Information We Collect</h2>
<p><strong>Information you provide directly:</strong></p>
<ul>
<li><strong>Account data:</strong> email address, password (stored as a hashed value we never see your plaintext password), and display name.</li>
<li><strong>Google OAuth data:</strong> if you sign in with Google, we receive your Google account email, name, and profile picture URL as provided by Google.</li>
<li><strong>Payment data:</strong> when you purchase credits, payment is processed by a third-party payment processor. We receive only a transaction ID and purchase amount we do not store your card details.</li>
<li><strong>User Content:</strong> documents, images, and files you upload for recognition processing.</li>
<li><strong>Support communications:</strong> messages you send to our support team.</li>
</ul>
<p><strong>Information collected automatically:</strong></p>
<ul>
<li><strong>Usage data:</strong> pages visited, features used, recognition tasks submitted, timestamps, and error logs.</li>
<li><strong>Device and technical data:</strong> IP address, browser type, operating system, and referring URL.</li>
<li><strong>Cookies and local storage:</strong> authentication tokens (JWT stored in localStorage), language preference, and guest usage count. See our <Link to="/cookies">Cookie Policy</Link> for details.</li>
</ul>
<h2>2. How We Use Your Information</h2>
<ul>
<li>Provide, operate, and improve the Service.</li>
<li>Authenticate your identity and maintain your session.</li>
<li>Process recognition tasks you submit.</li>
<li>Manage your credit balance and transaction history.</li>
<li>Send transactional emails: verification codes, payment receipts, and account security alerts.</li>
<li>Detect and prevent fraud, abuse, and violations of our Terms of Service.</li>
<li>Analyze aggregate usage patterns to improve the Service (using anonymized or pseudonymized data).</li>
<li>Comply with legal obligations.</li>
</ul>
<p>
We do <strong>not</strong> use your User Content to train AI or machine learning models without your explicit,
opt-in consent. We do not sell your personal information to third parties.
</p>
<h2>3. Information Sharing</h2>
<p>We share your information only in the following circumstances:</p>
<ul>
<li><strong>Service providers:</strong> cloud object storage (for file storage), email delivery services (for verification codes and notifications), and payment processors. These parties process data on our behalf under data processing agreements.</li>
<li><strong>Google:</strong> when you use Google OAuth, data is exchanged with Google in accordance with Google's Privacy Policy.</li>
<li><strong>Legal compliance:</strong> we may disclose information if required by law, court order, or governmental authority, or to protect the rights, property, or safety of TexPixel, our users, or the public.</li>
<li><strong>Business transfers:</strong> in the event of a merger, acquisition, or sale of assets, your information may be transferred to the successor entity, subject to the same privacy protections.</li>
</ul>
<h2>4. Data Retention</h2>
<ul>
<li><strong>Account data:</strong> retained for as long as your account is active, plus a 30-day grace period after deletion.</li>
<li><strong>User Content:</strong> uploaded files are retained for up to 90 days after task completion, then permanently deleted unless you save them to your account history.</li>
<li><strong>Task history:</strong> recognition results in your account history are retained until you delete them or close your account.</li>
<li><strong>Payment records:</strong> retained for 7 years as required by applicable financial regulations.</li>
<li><strong>Log data:</strong> server and access logs are retained for up to 90 days.</li>
</ul>
<h2>5. Data Security</h2>
<p>
We implement industry-standard security measures including TLS encryption for data in transit, encrypted storage
for sensitive credentials, and access controls limiting who can access production data. However, no system is
completely secure. We encourage you to use a strong, unique password and to contact us immediately if you suspect
unauthorized access to your account.
</p>
<h2>6. Your Rights</h2>
<p>
Depending on your location, you may have the following rights regarding your personal data:
</p>
<ul>
<li><strong>Access:</strong> request a copy of the personal data we hold about you.</li>
<li><strong>Correction:</strong> request correction of inaccurate or incomplete data.</li>
<li><strong>Deletion:</strong> request deletion of your personal data ("right to be forgotten"), subject to legal retention obligations.</li>
<li><strong>Portability:</strong> receive your data in a structured, machine-readable format.</li>
<li><strong>Objection / Restriction:</strong> object to or request restriction of certain processing activities.</li>
<li><strong>Withdraw Consent:</strong> where processing is based on consent, withdraw that consent at any time.</li>
</ul>
<p>
To exercise any of these rights, contact us at <a href="mailto:privacy@texpixel.com">privacy@texpixel.com</a>.
We will respond within 30 days. We may need to verify your identity before processing your request.
</p>
<h2>7. Children's Privacy</h2>
<p>
The Service is not directed to children under 13. We do not knowingly collect personal information from children
under 13. If we become aware that a child under 13 has provided us with personal information, we will delete it
promptly. If you believe a child has provided us with their data, please contact us immediately.
</p>
<h2>8. International Data Transfers</h2>
<p>
TexPixel operates primarily from servers located in China. If you access the Service from outside China,
your data will be transferred to and processed in China. By using the Service, you consent to this transfer.
We take appropriate safeguards to ensure your data is treated in accordance with this Privacy Policy regardless
of where it is processed.
</p>
<h2>9. Third-Party Links</h2>
<p>
The Service may contain links to third-party websites. This Privacy Policy does not apply to those sites.
We encourage you to review the privacy policies of any third-party services you visit.
</p>
<h2>10. Changes to This Policy</h2>
<p>
We may update this Privacy Policy from time to time. We will notify you of material changes via email or a
prominent notice on the Service at least 14 days before the changes take effect. The "Effective" date at the
top of this page indicates when the policy was last revised.
</p>
<h2>11. Contact Us</h2>
<p>
For privacy-related questions or to exercise your rights, contact us at{' '}
<a href="mailto:privacy@texpixel.com">privacy@texpixel.com</a>.
</p>
</>
);
}
function PrivacyZh() {
return (
<>
<p>
TexPixel"我们"使
使
</p>
<h2>1. </h2>
<p><strong></strong></p>
<ul>
<li><strong></strong></li>
<li><strong>Google OAuth </strong> Google Google URL</li>
<li><strong></strong> ID </li>
<li><strong></strong></li>
<li><strong></strong></li>
</ul>
<p><strong></strong></p>
<ul>
<li><strong>使</strong>访使</li>
<li><strong></strong>IP URL</li>
<li><strong>Cookie </strong>JWT localStorage访使 <Link to="/cookies">Cookie </Link></li>
</ul>
<h2>2. 使</h2>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li>使使</li>
<li></li>
</ul>
<p>
<strong></strong>使 AI
</p>
<h2>3. </h2>
<p></p>
<ul>
<li><strong></strong></li>
<li><strong>Google</strong>使 Google OAuth Google Google </li>
<li><strong></strong> TexPixel</li>
<li><strong></strong></li>
</ul>
<h2>4. </h2>
<ul>
<li><strong></strong> 30 </li>
<li><strong></strong> 90 </li>
<li><strong></strong></li>
<li><strong></strong> 7 </li>
<li><strong></strong>访 90 </li>
</ul>
<h2>5. </h2>
<p>
TLS
访访
使怀访
</p>
<h2>6. </h2>
<p></p>
<ul>
<li><strong>访</strong></li>
<li><strong></strong></li>
<li><strong></strong>"被遗忘权"</li>
<li><strong></strong></li>
<li><strong>/</strong></li>
<li><strong></strong></li>
</ul>
<p>
使{' '}
<a href="mailto:privacy@texpixel.com">privacy@texpixel.com</a>
30
</p>
<h2>7. </h2>
<p>
13 13
13
</p>
<h2>8. </h2>
<p>
TexPixel 访
使
</p>
<h2>9. </h2>
<p>
访
</p>
<h2>10. </h2>
<p>
14 "生效日期"
</p>
<h2>11. </h2>
<p>
使{' '}
<a href="mailto:privacy@texpixel.com">privacy@texpixel.com</a>
</p>
</>
);
}

322
src/pages/TermsPage.tsx Normal file
View File

@@ -0,0 +1,322 @@
import { Link } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import SEOHead from '../components/seo/SEOHead';
const EFFECTIVE_DATE_EN = 'March 26, 2026';
const EFFECTIVE_DATE_ZH = '2026年3月26日';
export default function TermsPage() {
const { language } = useLanguage();
const zh = language === 'zh';
return (
<>
<SEOHead
title={zh ? '服务条款 — TexPixel' : 'Terms of Service — TexPixel'}
description={zh
? 'TexPixel 服务条款:账户注册、积分制付费、用户内容与使用限制。'
: 'TexPixel Terms of Service: account registration, credit-based billing, user content, and usage restrictions.'}
path="/terms"
/>
<div className="docs-detail">
<Link to="/" className="docs-back-link">
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6" /></svg>
{zh ? '返回首页' : 'Back to home'}
</Link>
<div className="docs-article-header">
<div className="docs-article-tags">
<span className="docs-tag">Legal</span>
</div>
<h1 className="docs-article-h1">{zh ? '服务条款' : 'Terms of Service'}</h1>
<div className="docs-meta-row">
<span>{zh ? `生效日期:${EFFECTIVE_DATE_ZH}` : `Effective: ${EFFECTIVE_DATE_EN}`}</span>
<span className="docs-meta-sep">·</span>
<span>TexPixel</span>
</div>
</div>
<div className="docs-prose">
{zh ? <TermsZh /> : <TermsEn />}
</div>
</div>
</>
);
}
function TermsEn() {
return (
<>
<p>
Please read these Terms of Service ("Terms") carefully before using TexPixel ("Service", "we", "us", or "our").
By creating an account or using the Service, you agree to be bound by these Terms. If you do not agree, do not use the Service.
</p>
<h2>1. Eligibility</h2>
<p>
You must be at least 13 years old to use the Service. If you are under 18, you must have your parent or legal guardian's
permission. By using the Service, you represent and warrant that you meet these requirements. Paid features (credits
purchases) are available only to users who are 18 or older or have legal capacity to enter into contracts in their jurisdiction.
</p>
<h2>2. Account Registration</h2>
<p>
You may register using an email address and one-time verification code, or by linking a Google account via OAuth 2.0.
You are responsible for maintaining the confidentiality of your account credentials and for all activities that occur
under your account. You must provide accurate, current, and complete information and keep your account information updated.
We reserve the right to suspend or terminate accounts that contain false information or that are used fraudulently.
</p>
<h2>3. Credits & Payment</h2>
<p>
Access to certain features of the Service requires purchasing credits. Credits are a prepaid, non-refundable virtual
currency used to pay for recognition tasks (image-to-LaTeX, PDF-to-Markdown, handwriting OCR, etc.).
</p>
<ul>
<li><strong>No Refunds.</strong> Credits are non-refundable except where required by applicable law. If you believe
you were charged in error, contact us within 30 days of the charge.</li>
<li><strong>Expiration.</strong> Credits do not expire as long as your account remains active. We reserve the right
to introduce expiration policies with at least 60 days' notice.</li>
<li><strong>Price Changes.</strong> We may change credit prices at any time. Changes will not affect credits already purchased.</li>
<li><strong>Taxes.</strong> You are responsible for all applicable taxes on credit purchases in your jurisdiction.</li>
<li><strong>Free Tier.</strong> Guest users receive a limited number of free recognition tasks. Free usage is subject
to change without notice.</li>
<li><strong>Chargebacks.</strong> Initiating a chargeback without first contacting us may result in immediate account suspension.</li>
</ul>
<h2>4. User Content</h2>
<p>
You retain all intellectual property rights in the documents, images, and files you upload ("User Content"). By
uploading User Content, you grant TexPixel a limited, non-exclusive, royalty-free license to process, store, and
transmit your content solely to provide the Service.
</p>
<ul>
<li>We do not use your User Content to train AI models without your explicit consent.</li>
<li>We do not share your User Content with third parties except as necessary to operate the Service (e.g., cloud
storage providers).</li>
<li>You are responsible for ensuring you have the right to upload and process any content you submit.</li>
<li>Do not upload content that is illegal, infringes third-party rights, or contains personally identifiable
information of others without their consent.</li>
</ul>
<h2>5. Accuracy Disclaimer</h2>
<p>
TexPixel uses AI-based recognition technology. Recognition results (LaTeX, Markdown, text) are provided on an
"as-is" basis. We do not warrant that results will be accurate, complete, or suitable for any particular purpose.
You are responsible for verifying all outputs before use in academic, professional, or published work.
</p>
<h2>6. Prohibited Uses</h2>
<p>You agree not to:</p>
<ul>
<li>Use the Service to process content that is illegal, obscene, defamatory, or violates third-party rights.</li>
<li>Reverse-engineer, decompile, or attempt to extract source code from the Service.</li>
<li>Automate requests to the Service in a manner that exceeds your plan limits or degrades service for others.</li>
<li>Use the Service to build a competing product or service without our written permission.</li>
<li>Share, sell, or transfer your account or credits to another party.</li>
<li>Use bots, scrapers, or automated tools to access the Service beyond what is permitted by the API documentation.</li>
<li>Circumvent any technical measures we use to limit usage or enforce these Terms.</li>
</ul>
<h2>7. Intellectual Property</h2>
<p>
The Service, including its software, design, trademarks, and content (excluding User Content), is owned by TexPixel
and protected by applicable intellectual property laws. You may not copy, modify, distribute, sell, or lease any part
of the Service without our prior written consent.
</p>
<h2>8. Third-Party Services</h2>
<p>
The Service integrates with third-party services including Google (for OAuth), cloud object storage providers (for
file storage), and payment processors. Your use of these services is also subject to their respective terms and
privacy policies. We are not responsible for the practices of third-party services.
</p>
<h2>9. Disclaimer of Warranties</h2>
<p>
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
WE DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE.
</p>
<h2>10. Limitation of Liability</h2>
<p>
TO THE MAXIMUM EXTENT PERMITTED BY LAW, TEXPIXEL SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL,
CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING LOSS OF DATA, LOSS OF PROFITS, OR BUSINESS INTERRUPTION, ARISING
FROM YOUR USE OF THE SERVICE, EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
</p>
<p>
OUR TOTAL LIABILITY TO YOU FOR ANY CLAIMS ARISING FROM YOUR USE OF THE SERVICE SHALL NOT EXCEED THE AMOUNT YOU
PAID US IN THE 12 MONTHS PRECEDING THE CLAIM.
</p>
<h2>11. Termination</h2>
<p>
We may suspend or terminate your account at any time, with or without notice, for conduct that we determine violates
these Terms or is harmful to other users, us, or third parties. You may delete your account at any time by contacting
us. Upon termination, your right to use the Service ceases immediately. Unused credits at the time of termination for
cause are forfeited; credits remaining upon voluntary account closure may be refunded at our discretion.
</p>
<h2>12. Indemnification</h2>
<p>
You agree to indemnify, defend, and hold harmless TexPixel and its officers, directors, employees, and agents from
any claims, liabilities, damages, and expenses (including reasonable attorneys' fees) arising from your use of the
Service, your User Content, or your violation of these Terms.
</p>
<h2>13. Governing Law & Dispute Resolution</h2>
<p>
These Terms are governed by the laws of the People's Republic of China without regard to conflict-of-law principles.
Any disputes arising from these Terms shall first be subject to good-faith negotiation. If unresolved within 30 days,
disputes shall be submitted to binding arbitration in accordance with applicable rules, or to the competent courts of
the jurisdiction where TexPixel is incorporated.
</p>
<h2>14. Changes to These Terms</h2>
<p>
We may modify these Terms at any time. We will provide notice of material changes via email or a prominent notice on
the Service at least 14 days before changes take effect. Your continued use of the Service after the effective date
constitutes your acceptance of the revised Terms.
</p>
<h2>15. Contact Us</h2>
<p>
If you have questions about these Terms, please contact us at{' '}
<a href="mailto:legal@texpixel.com">legal@texpixel.com</a>.
</p>
</>
);
}
function TermsZh() {
return (
<>
<p>
使 TexPixel"服务""我们""条款"
使使
</p>
<h2>1. 使</h2>
<p>
13 使 18
使
</p>
<h2>2. </h2>
<p>
Google OAuth 2.0 Google
</p>
<h2>3. </h2>
<p>
使 LaTeXPDF Markdown
</p>
<ul>
<li><strong>退</strong>退 30 </li>
<li><strong></strong> 60 </li>
<li><strong></strong></li>
<li><strong></strong></li>
<li><strong></strong>访</li>
<li><strong></strong></li>
</ul>
<h2>4. </h2>
<p>
"用户内容" TexPixel
</p>
<ul>
<li>使 AI </li>
<li></li>
<li></li>
<li></li>
</ul>
<h2>5. </h2>
<p>
TexPixel AI LaTeXMarkdown"现状"
</p>
<h2>6. </h2>
<p></p>
<ul>
<li>使</li>
<li></li>
<li></li>
<li></li>
<li></li>
<li>使 API 访</li>
<li>使</li>
</ul>
<h2>7. </h2>
<p>
TexPixel
</p>
<h2>8. </h2>
<p>
Google OAuth
使
</p>
<h2>9. </h2>
<p>
"现状""可用状态"
</p>
<h2>10. </h2>
<p>
TexPixel 使
便
</p>
<p>
使 12
</p>
<h2>11. </h2>
<p>
使
使退
</p>
<h2>12. </h2>
<p>
使
TexPixel
</p>
<h2>13. </h2>
<p>
30 TexPixel
</p>
<h2>14. </h2>
<p>
14 使
</p>
<h2>15. </h2>
<p>
{' '}
<a href="mailto:legal@texpixel.com">legal@texpixel.com</a>
</p>
</>
);
}

564
src/pages/WorkspacePage.tsx Normal file
View File

@@ -0,0 +1,564 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { uploadService } from '../lib/uploadService';
import { FileRecord, RecognitionResult } from '../types';
import { TaskStatus, TaskHistoryItem } from '../types/api';
import LeftSidebar from '../components/LeftSidebar';
import FilePreview from '../components/FilePreview';
import ResultPanel from '../components/ResultPanel';
import UploadModal from '../components/UploadModal';
import UserGuide from '../components/UserGuide';
import AuthModal from '../components/AuthModal';
import SEOHead from '../components/seo/SEOHead';
const PAGE_SIZE = 6;
const GUEST_USAGE_LIMIT = 3;
const GUEST_USAGE_COUNT_KEY = 'texpixel_guest_usage_count';
export default function WorkspacePage() {
const { user, initializing } = useAuth();
const { t } = useLanguage();
const [files, setFiles] = useState<FileRecord[]>([]);
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showUserGuide, setShowUserGuide] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [loading, setLoading] = useState(false);
const [guestUsageCount, setGuestUsageCount] = useState<number>(() => {
const storedCount = localStorage.getItem(GUEST_USAGE_COUNT_KEY);
const parsedCount = storedCount ? Number.parseInt(storedCount, 10) : 0;
return Number.isFinite(parsedCount) ? parsedCount : 0;
});
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [historyEnabled, setHistoryEnabled] = useState(false);
const [authModalMandatory, setAuthModalMandatory] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(320);
const [isResizing, setIsResizing] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
const pollingIntervals = useRef<Record<string, NodeJS.Timeout>>({});
const selectedFileIdRef = useRef<string | null>(null);
const resultsCache = useRef<Record<string, RecognitionResult>>({});
const hasLoadedFiles = useRef(false);
const demoLoadedRef = useRef(false);
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
const canUploadAnonymously = !user && guestUsageCount < GUEST_USAGE_LIMIT;
const openAuthModal = useCallback((mandatory = false) => {
setAuthModalMandatory(mandatory);
setShowAuthModal(true);
}, []);
const handleToggleHistory = useCallback(() => {
if (!historyEnabled) {
if (!user) {
openAuthModal();
return;
}
setHistoryEnabled(true);
if (!hasLoadedFiles.current) {
hasLoadedFiles.current = true;
loadFiles();
}
} else {
setHistoryEnabled(false);
}
}, [historyEnabled, user, openAuthModal]);
useEffect(() => {
const handleStartGuide = () => setShowUserGuide(true);
window.addEventListener('start-user-guide', handleStartGuide);
return () => window.removeEventListener('start-user-guide', handleStartGuide);
}, []);
useEffect(() => {
if (initializing) return;
if (demoLoadedRef.current) return;
const demoImageSrc = sessionStorage.getItem('texpixel_demo_image');
if (!demoImageSrc) return;
demoLoadedRef.current = true;
sessionStorage.removeItem('texpixel_demo_image');
fetch(demoImageSrc)
.then((res) => res.blob())
.then((blob) => {
const fileName = demoImageSrc.split('/').pop() || 'demo.png';
const file = new File([blob], fileName, { type: blob.type || 'image/png' });
handleUpload([file]);
})
.catch((err) => console.error('Failed to load demo image:', err));
}, [initializing]);
useEffect(() => {
if (user && !hasLoadedFiles.current && historyEnabled) {
hasLoadedFiles.current = true;
loadFiles();
}
if (!user) {
hasLoadedFiles.current = false;
setHistoryEnabled(false);
setFiles([]);
setSelectedFileId(null);
setCurrentPage(1);
setHasMore(false);
}
}, [user]);
useEffect(() => {
selectedFileIdRef.current = selectedFileId;
if (selectedFileId) {
loadResult(selectedFileId);
} else {
setSelectedResult(null);
}
}, [selectedFileId]);
useEffect(() => {
return () => {
Object.values(pollingIntervals.current).forEach(clearInterval);
pollingIntervals.current = {};
};
}, []);
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
if (showUploadModal) return;
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
const items = e.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/') || items[i].type === 'application/pdf') {
const file = items[i].getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0) {
handleUpload(files);
}
};
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [guestUsageCount, openAuthModal, showUploadModal, user]);
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
mouseDownEvent.preventDefault();
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const resize = useCallback(
(mouseMoveEvent: MouseEvent) => {
if (isResizing) {
const newWidth = mouseMoveEvent.clientX;
if (newWidth >= 280 && newWidth <= 400) {
setSidebarWidth(newWidth);
}
}
},
[isResizing]
);
useEffect(() => {
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResizing);
return () => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
};
}, [resize, stopResizing]);
const convertToFileRecord = (item: TaskHistoryItem): FileRecord => {
const statusMap: Record<number, FileRecord['status']> = {
[TaskStatus.Pending]: 'pending',
[TaskStatus.Processing]: 'processing',
[TaskStatus.Completed]: 'completed',
[TaskStatus.Failed]: 'failed',
};
return {
id: item.task_id,
user_id: user?.id || null,
filename: item.file_name,
file_path: item.origin_url,
file_type: 'image/jpeg',
file_size: 0,
thumbnail_path: null,
status: statusMap[item.status] || 'pending',
created_at: item.created_at,
updated_at: item.created_at,
};
};
const convertToRecognitionResult = (item: TaskHistoryItem): RecognitionResult => {
return {
id: item.task_id,
file_id: item.task_id,
markdown_content: item.markdown,
latex_content: item.latex,
mathml_content: item.mathml,
mml: item.mml,
rendered_image_path: item.image_blob || null,
created_at: item.created_at,
};
};
const loadFiles = async () => {
if (!user) return;
setLoading(true);
try {
const data = await uploadService.getTaskList('FORMULA', 1, PAGE_SIZE);
const taskList = data.task_list || [];
const total = data.total || 0;
setCurrentPage(1);
setHasMore(taskList.length < total);
if (taskList.length > 0) {
const fileRecords = taskList.map(convertToFileRecord);
setFiles(fileRecords);
taskList.forEach(item => {
if (item.status === TaskStatus.Completed) {
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
}
});
} else {
setFiles([]);
}
} catch (error) {
console.error('Error loading files:', error);
setFiles([]);
} finally {
setLoading(false);
}
};
const loadMoreFiles = async () => {
if (!user || loadingMore || !hasMore) return;
setLoadingMore(true);
try {
const nextPage = currentPage + 1;
const data = await uploadService.getTaskList('FORMULA', nextPage, PAGE_SIZE);
const taskList = data.task_list || [];
const total = data.total || 0;
if (taskList.length > 0) {
const newFileRecords = taskList.map(convertToFileRecord);
setFiles(prev => {
const newFiles = [...prev, ...newFileRecords];
setHasMore(newFiles.length < total);
return newFiles;
});
taskList.forEach(item => {
if (item.status === TaskStatus.Completed) {
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
}
});
setCurrentPage(nextPage);
} else {
setHasMore(false);
}
} catch (error) {
console.error('Error loading more files:', error);
} finally {
setLoadingMore(false);
}
};
const loadResult = async (fileId: string) => {
try {
const cachedResult = resultsCache.current[fileId];
if (cachedResult) {
setSelectedResult(cachedResult);
return;
}
try {
const result = await uploadService.getTaskResult(fileId);
if (result.status === TaskStatus.Completed) {
const recognitionResult: RecognitionResult = {
id: fileId,
file_id: fileId,
markdown_content: result.markdown,
latex_content: result.latex,
mathml_content: result.mathml,
mml: result.mml,
rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString(),
};
resultsCache.current[fileId] = recognitionResult;
setSelectedResult(recognitionResult);
} else {
setSelectedResult(null);
}
} catch {
setSelectedResult(null);
}
} catch (error) {
console.error('Error loading result:', error);
setSelectedResult(null);
}
};
const startPolling = (taskNo: string, fileId: string) => {
if (pollingIntervals.current[taskNo]) return;
let attempts = 0;
const maxAttempts = 30;
pollingIntervals.current[taskNo] = setInterval(async () => {
attempts++;
try {
const result = await uploadService.getTaskResult(taskNo);
setFiles(prevFiles => prevFiles.map(f => {
if (f.id === fileId) {
let status: FileRecord['status'] = 'processing';
if (result.status === TaskStatus.Completed) status = 'completed';
else if (result.status === TaskStatus.Failed) status = 'failed';
return { ...f, status };
}
return f;
}));
if (result.status === TaskStatus.Completed || result.status === TaskStatus.Failed) {
clearInterval(pollingIntervals.current[taskNo]);
delete pollingIntervals.current[taskNo];
if (result.status === TaskStatus.Completed) {
const recognitionResult: RecognitionResult = {
id: fileId,
file_id: fileId,
markdown_content: result.markdown,
latex_content: result.latex,
mathml_content: result.mathml,
mml: result.mml,
rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString()
};
resultsCache.current[fileId] = recognitionResult;
if (selectedFileIdRef.current === fileId) {
setSelectedResult(recognitionResult);
}
}
} else if (attempts >= maxAttempts) {
clearInterval(pollingIntervals.current[taskNo]);
delete pollingIntervals.current[taskNo];
setFiles(prevFiles => prevFiles.map(f => {
if (f.id === fileId) return { ...f, status: 'failed' };
return f;
}));
alert(t.alerts.taskTimeout);
}
} catch (error) {
console.error('Polling error:', error);
if (attempts >= maxAttempts) {
clearInterval(pollingIntervals.current[taskNo]);
delete pollingIntervals.current[taskNo];
setFiles(prevFiles => prevFiles.map(f => {
if (f.id === fileId) return { ...f, status: 'failed' };
return f;
}));
alert(t.alerts.networkError);
}
}
}, 2000);
};
const handleUpload = async (uploadFiles: File[]) => {
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
setLoading(true);
try {
let successfulUploads = 0;
for (const file of uploadFiles) {
const fileHash = await uploadService.calculateMD5(file);
const signatureData = await uploadService.uploadFile(file);
const taskData = await uploadService.createRecognitionTask(
signatureData.path,
fileHash,
file.name
);
const fileId = taskData.task_no || crypto.randomUUID();
const newFile: FileRecord = {
id: fileId,
user_id: user?.id || null,
filename: file.name,
file_path: URL.createObjectURL(file),
file_type: file.type,
file_size: file.size,
thumbnail_path: null,
status: 'processing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
setFiles(prevFiles => [newFile, ...prevFiles]);
setSelectedFileId(newFile.id);
if (taskData.task_no) {
startPolling(taskData.task_no, fileId);
}
successfulUploads += 1;
}
if (!user && successfulUploads > 0) {
const nextCount = guestUsageCount + successfulUploads;
localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(nextCount));
setGuestUsageCount(nextCount);
if (nextCount >= GUEST_USAGE_LIMIT) {
openAuthModal(true);
}
}
} catch (error) {
console.error('Error uploading files:', error);
alert(`${t.alerts.uploadFailed}: ` + (error instanceof Error ? error.message : 'Unknown error'));
} finally {
setLoading(false);
}
};
if (initializing) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">{t.common.loading}</p>
</div>
</div>
);
}
return (
<>
<SEOHead
title="Workspace"
description="TexPixel formula recognition workspace"
path="/app"
noindex
/>
{/* Left Sidebar */}
<div
ref={sidebarRef}
className="flex-shrink-0 bg-white border-r border-gray-200 relative transition-all duration-300 ease-in-out"
style={{ width: sidebarCollapsed ? 64 : sidebarWidth }}
>
<LeftSidebar
files={files}
selectedFileId={selectedFileId}
onFileSelect={setSelectedFileId}
onUploadClick={() => {
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
setShowUploadModal(true);
}}
canUploadAnonymously={canUploadAnonymously}
onRequireAuth={openAuthModal}
isCollapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
onUploadFiles={handleUpload}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMoreFiles}
historyEnabled={historyEnabled}
onToggleHistory={handleToggleHistory}
/>
{!sidebarCollapsed && (
<div
className="absolute right-0 top-0 w-1 h-full cursor-col-resize hover:bg-blue-400 z-50 opacity-0 hover:opacity-100 transition-opacity"
onMouseDown={startResizing}
/>
)}
</div>
{/* Middle Content: File Preview */}
<div className="flex-1 flex min-w-0 flex-col bg-gray-100/50">
<FilePreview file={selectedFile} />
</div>
{/* Right Result: Recognition Result */}
<div className="flex-1 flex min-w-0 flex-col bg-white">
<ResultPanel
result={selectedResult}
fileStatus={selectedFile?.status}
/>
</div>
{showUploadModal && (
<UploadModal
onClose={() => setShowUploadModal(false)}
onUpload={handleUpload}
/>
)}
{showAuthModal && (
<AuthModal
onClose={() => {
setShowAuthModal(false);
setAuthModalMandatory(false);
}}
mandatory={authModalMandatory}
/>
)}
<UserGuide
isOpen={showUserGuide}
onClose={() => setShowUserGuide(false)}
/>
{loading && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-8">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-900 font-medium">{t.common.processing}</p>
</div>
</div>
)}
</>
);
}

View File

@@ -1,12 +1,50 @@
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import App from '../App';
import MarketingLayout from '../components/layout/MarketingLayout';
import AppLayout from '../components/layout/AppLayout';
import AuthCallbackPage from '../pages/AuthCallbackPage';
const HomePage = lazy(() => import('../pages/HomePage'));
const WorkspacePage = lazy(() => import('../pages/WorkspacePage'));
const DocsListPage = lazy(() => import('../pages/DocsListPage'));
const DocDetailPage = lazy(() => import('../pages/DocDetailPage'));
const BlogListPage = lazy(() => import('../pages/BlogListPage'));
const BlogDetailPage = lazy(() => import('../pages/BlogDetailPage'));
const TermsPage = lazy(() => import('../pages/TermsPage'));
const PrivacyPage = lazy(() => import('../pages/PrivacyPage'));
const CookiePolicyPage = lazy(() => import('../pages/CookiePolicyPage'));
const AboutPage = lazy(() => import('../pages/AboutPage'));
const ContactPage = lazy(() => import('../pages/ContactPage'));
function LoadingFallback() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
export default function AppRouter() {
return (
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<App />} />
<Route element={<MarketingLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/docs" element={<DocsListPage />} />
<Route path="/docs/:slug" element={<DocDetailPage />} />
<Route path="/blog" element={<BlogListPage />} />
<Route path="/blog/:slug" element={<BlogDetailPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/cookies" element={<CookiePolicyPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
</Route>
<Route element={<AppLayout />}>
<Route path="/app" element={<WorkspacePage />} />
</Route>
<Route path="/auth/google/callback" element={<AuthCallbackPage />} />
</Routes>
</Suspense>
);
}

2743
src/styles/landing.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,11 @@ export interface LoginRequest {
export interface RegisterRequest {
email: string;
password: string;
code: string;
}
export interface SendEmailCodeRequest {
email: string;
}
// OSS 签名响应数据

View File

@@ -2,7 +2,51 @@
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
extend: {
colors: {
coral: {
50: '#fef0ec',
100: '#fdddd5',
200: '#fbb9ab',
300: '#f48e78',
400: '#e86f54',
500: '#e05a33',
600: '#c94a28',
700: '#a53d22',
800: '#84331e',
900: '#6c2c1c',
},
ink: {
DEFAULT: '#1c1917',
secondary: '#57534e',
muted: '#a8a29e',
},
cream: {
50: '#fdfcfa',
100: '#faf8f4',
200: '#f5f3ee',
300: '#e7e5e4',
},
sage: {
50: '#eef9f7',
100: '#d5f0eb',
200: '#aee0d7',
300: '#7ccbbe',
400: '#4db3a4',
500: '#2a9d8f',
600: '#1f7d72',
700: '#1c655d',
},
},
fontFamily: {
display: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
body: ['"DM Sans"', 'system-ui', 'sans-serif'],
},
borderRadius: {
'2xl': '1rem',
'3xl': '1.25rem',
},
},
},
plugins: [
require('@tailwindcss/typography'),

View File

@@ -0,0 +1,29 @@
<svg width="1024" height="1024" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="line1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="25%" stop-color="#ffffff" stop-opacity="0.92"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.92"/>
</linearGradient>
<linearGradient id="line2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="30%" stop-color="#ffffff" stop-opacity="0.62"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.62"/>
</linearGradient>
<linearGradient id="line3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="28%" stop-color="#ffffff" stop-opacity="0.38"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.38"/>
</linearGradient>
<linearGradient id="line4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="35%" stop-color="#ffffff" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#000000"/>
<rect x="24" y="42" width="74" height="3" rx="1.5" fill="url(#line1)"/>
<rect x="26" y="56" width="56" height="3" rx="1.5" fill="url(#line2)"/>
<rect x="24" y="70" width="64" height="3" rx="1.5" fill="url(#line3)"/>
<rect x="28" y="84" width="44" height="3" rx="1.5" fill="url(#line4)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Some files were not shown because too many files have changed in this diff Show More