Compare commits
1 Commits
feat/websi
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e90fca5ab1 |
@@ -1,177 +0,0 @@
|
||||
|
||||
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
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
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
@@ -23,4 +23,3 @@ dist-ssr
|
||||
.env
|
||||
/dist
|
||||
app.cloud/
|
||||
public/content/
|
||||
|
||||
76
CLAUDE.md
@@ -1,76 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
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 88–95% vs. 95–99%. 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)
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
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 10–15 formulas, this takes about 2 minutes. Compare that to 20–30 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)
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
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 5–10 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 15–20 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 20–30 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)
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
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>∫</mo><mn>0</mn><mi>∞</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.
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
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!
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
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 的模型在大量多样化的手写数学数据集上训练,以应对这种变化。但手写的准确率始终低于印刷体——通常为 88–95% 对比 95–99%。[手写技巧指南](/blog/handwriting-tips)中的建议可以将准确率推向上限。
|
||||
|
||||
## 整个流程在一秒内完成
|
||||
|
||||
预处理 → 符号检测 → 结构解析 → LaTeX 生成:所有这些在不到一秒内完成。这是精心设计的流水线,不是魔法——但第一次尝试时的速度仍然会让大多数人感到惊讶。
|
||||
|
||||
[上传公式,亲身体验 →](/app)
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
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. 对每张公式照片重复此操作
|
||||
|
||||
一节课有 10–15 个公式,整个过程约 2 分钟。相比手动录入的 20–30 分钟,差距显著。
|
||||
|
||||
### 做作业时
|
||||
|
||||
在解题过程中:
|
||||
|
||||
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)
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
title: "用 TexPixel 数字化十年科研笔记"
|
||||
description: 研究人员如何用 TexPixel 将多年手写数学笔记转换为可搜索、可编辑的 LaTeX 文档
|
||||
slug: researcher-workflow
|
||||
date: 2026-03-08
|
||||
tags: [工作流, 科研, 教程]
|
||||
---
|
||||
|
||||
# 用 TexPixel 数字化十年科研笔记
|
||||
|
||||
研究人员会积累笔记本。会议上草拟的推导、印刷论文上的旁注、组会白板的拍照、凌晨三点写了一半的证明。在很长一段时间里,这些材料实际上是不可搜索的——被困在物理形态中,只能翻翻一叠叠笔记本才能找到。
|
||||
|
||||
TexPixel 改变了这个局面。
|
||||
|
||||
## 问题的规模
|
||||
|
||||
一个活跃的研究人员每年可能积累 5–10 本填满的笔记本,每本包含数百个方程式。手动数字化——逐个用 LaTeX 重新输入公式——几乎是不可能完成的任务。按每个公式 3 分钟、每本 50 个公式计算,一年的笔记需要 400 多小时才能手动转录。
|
||||
|
||||
用 TexPixel,每个公式从拍照到 LaTeX 不到 5 秒。同样一年的笔记:不到 7 小时。
|
||||
|
||||
## 实用数字化工作流
|
||||
|
||||
### 第一步:拍摄笔记本
|
||||
|
||||
使用摄像头好的手机和文档扫描 App(Adobe Scan、Microsoft Lens 或 Apple 内置文档扫描仪)。这些 App 能够:
|
||||
- 自动检测页面边缘
|
||||
- 校正透视畸变
|
||||
- 对褪色墨水或铅笔字迹进行对比度增强
|
||||
- 导出为 PDF
|
||||
|
||||
扫描一整本笔记本需要 15–20 分钟。
|
||||
|
||||
### 第二步:确定公式密集的页面
|
||||
|
||||
不是每页都需要数字化。快速翻阅并标记包含你实际需要的方程式的页面。即使周围的文字不需要,一个关键推导或一组方程式往往也值得数字化。
|
||||
|
||||
### 第三步:用 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))
|
||||
```
|
||||
|
||||
几次整理之后,你将拥有一份可搜索、可编译的参考文档,所用时间只是手动转录的零头。
|
||||
|
||||
## 处理白板
|
||||
|
||||
会议室白板是特别有价值的目标。一次组会可能产生 20–30 个关键方程式,否则随着有人擦掉白板就消失了。
|
||||
|
||||
**最佳实践:** 在擦板前拍照(显而易见),但也要拍摄中间步骤——讨论推进过程中被覆盖的推导。中间步骤往往才是洞见所在。
|
||||
|
||||
白板拍摄注意事项:
|
||||
- 正对白板拍摄,不要斜着拍
|
||||
- 使用均匀光线——开灯不用闪光灯通常比用闪光灯更好,闪光灯会在光滑白板上产生眩光
|
||||
- 上传前将各个公式分别裁剪
|
||||
|
||||
## 处理印刷论文
|
||||
|
||||
对于有批注的印刷论文,TexPixel 可以提取印刷公式,也可以(以略低的准确率)识别手写旁注。紧密裁剪到需要的区域,将公式与旁注分开上传。
|
||||
|
||||
## 建立长期知识库
|
||||
|
||||
数字化的真正价值随时间复利增长。5 年笔记整理出的结构良好的 LaTeX 参考文档,你可以:
|
||||
- 用 `grep` 或编辑器搜索功能检索
|
||||
- 与引用管理器交叉引用
|
||||
- 与合作者共享
|
||||
- 写新论文时直接在此基础上构建
|
||||
|
||||
从过去一年的笔记本开始。7 小时的投入,将带来多年的回报。
|
||||
|
||||
**参考文档:** 关于 PDF 文件限制、支持类型和导出选项,请查看 [PDF 公式提取文档 →](/docs/pdf-extraction)
|
||||
|
||||
[开始数字化你的笔记 →](/app)
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
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 和图片之间自由切换。
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
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 访问等。敬请期待!
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
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) | 95–99% |
|
||||
| Clean handwritten formulas | 88–95% |
|
||||
| Scanned documents (300 DPI+) | 93–98% |
|
||||
| Photos of whiteboards | 82–92% |
|
||||
| Low-resolution images (< 72 DPI) | 60–80% |
|
||||
|
||||
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)
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
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 | 95–99% |
|
||||
| Scanned PDF (high quality) | 300 DPI scan of printed text | 90–97% |
|
||||
| Scanned PDF (low quality) | < 150 DPI or poor contrast | 60–80% |
|
||||
| Photo PDF | Photographed pages embedded as images | 75–90% |
|
||||
|
||||
## File Limits
|
||||
|
||||
- **Max file size:** 20 MB
|
||||
- **Max pages:** 50 pages per upload (Pro plan: unlimited)
|
||||
- **Processing time:** ~2–5 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 30–60 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)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
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 |
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
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 传输。上传的文件和结果都经过安全处理。
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
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 文件可以包含多页,所有公式都会被提取
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: 识别准确率
|
||||
description: 了解 TexPixel 识别准确率及如何获得最佳效果
|
||||
slug: ocr-accuracy
|
||||
date: 2026-03-25
|
||||
tags: [准确率, 技巧]
|
||||
order: 5
|
||||
---
|
||||
|
||||
# 识别准确率
|
||||
|
||||
TexPixel 在数学公式识别方面达到行业领先的准确率——但准确率在不同输入类型之间并不统一。本指南解释影响准确率的因素以及如何最大化识别效果。
|
||||
|
||||
## 按公式类型的准确率
|
||||
|
||||
| 公式类型 | 典型准确率 |
|
||||
|---|---|
|
||||
| 印刷体公式(教材、论文) | 95–99% |
|
||||
| 清晰手写公式 | 88–95% |
|
||||
| 扫描文档(300 DPI+) | 93–98% |
|
||||
| 白板照片 | 82–92% |
|
||||
| 低分辨率图片(< 72 DPI) | 60–80% |
|
||||
|
||||
这些是大致范围,实际结果在很大程度上取决于图片质量。
|
||||
|
||||
## 影响准确率的因素
|
||||
|
||||
### 图片质量
|
||||
|
||||
这是最重要的单一因素。模糊、低分辨率或光线不佳的图片效果始终不如清晰扫描件。
|
||||
|
||||
- **分辨率** — 建议 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)
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
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 或排版工具创建 | 95–99% |
|
||||
| 扫描 PDF(高质量) | 印刷文字的 300 DPI 扫描 | 90–97% |
|
||||
| 扫描 PDF(低质量) | < 150 DPI 或对比度差 | 60–80% |
|
||||
| 照片 PDF | 嵌入为图片的拍照页面 | 75–90% |
|
||||
|
||||
## 文件限制
|
||||
|
||||
- **最大文件大小:** 20 MB
|
||||
- **最大页数:** 每次上传 50 页(专业版:无限制)
|
||||
- **处理时间:** 每页约 2–5 秒
|
||||
|
||||
对于超出限制的文档,上传前将 PDF 分割成较小的部分。
|
||||
|
||||
## 导出 PDF 识别结果
|
||||
|
||||
提取后,可以多种方式导出:
|
||||
|
||||
- **复制单个公式** — 点击任意识别的公式复制其 LaTeX
|
||||
- **DOCX 导出** — 下载包含原生 Word 方程的完整文档
|
||||
- **批量复制** — 将所有公式复制为列表(专业版功能)
|
||||
|
||||
## 提高 PDF 识别效果的技巧
|
||||
|
||||
- **使用原始 PDF**,而非重新扫描的副本——矢量 PDF 效果最佳
|
||||
- **避免密码保护的 PDF**——这类文件无法处理
|
||||
- 如果 PDF 有很宽的空白边距,**裁剪页面**——较小的页面处理更快
|
||||
- 对于非常大的文档,**按章节分割**以保持在页数限制内
|
||||
|
||||
## 常见问题
|
||||
|
||||
**"未找到公式"**
|
||||
PDF 可能已加密,公式可能以复杂矢量路径存储,或使用了非标准编码。尝试将页面转换为 PNG 图片后再上传。
|
||||
|
||||
**公式已识别但内容乱码**
|
||||
这通常发生在非常低 DPI 的扫描件上。尝试在上传前使用 PDF 扫描应用以 300 DPI 重新扫描。
|
||||
|
||||
**处理速度慢**
|
||||
包含多页的大型 PDF 可能需要 30–60 秒。这是正常的,处理完成后结果会显示。
|
||||
|
||||
---
|
||||
|
||||
**延伸阅读:** [我试着从教授的 PDF 里提取公式——真实排障经历 →](/blog/pdf-formula-issues)
|
||||
|
||||
[上传 PDF 提取公式 →](/app)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
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 | 分享 | 否 | 是 |
|
||||
@@ -16,9 +16,6 @@ NC='\033[0m' # No Color
|
||||
ubuntu_HOST="ubuntu"
|
||||
DEPLOY_PATH="/var/www"
|
||||
DEPLOY_NAME="app.cloud"
|
||||
# Sudo 密码(如果需要,建议配置无密码 sudo 更安全)
|
||||
# 配置无密码 sudo: 在服务器上运行: echo "username ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/username
|
||||
SUDO_PASSWORD="1231"
|
||||
|
||||
# 打印带颜色的消息
|
||||
print_info() {
|
||||
@@ -62,61 +59,63 @@ deploy_to_server() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# SSH 执行部署操作
|
||||
# SSH 执行部署操作(非交互模式)
|
||||
print_info "在 ${server} 上执行部署操作..."
|
||||
print_info "部署路径: ${DEPLOY_PATH}/${DEPLOY_NAME}"
|
||||
# 注意:密码通过环境变量传递,避免在命令行中暴露
|
||||
ssh_output=$(ssh "${server}" "SSH_SUDO_PASSWORD='${SUDO_PASSWORD}' SSH_DEPLOY_PATH='${DEPLOY_PATH}' SSH_DEPLOY_NAME='${DEPLOY_NAME}' bash -s" << 'SSH_EOF'
|
||||
ssh_output=$(ssh ${server} bash << SSH_EOF
|
||||
set -e
|
||||
DEPLOY_PATH="${SSH_DEPLOY_PATH}"
|
||||
DEPLOY_NAME="${SSH_DEPLOY_NAME}"
|
||||
SUDO_PASSWORD="${SSH_SUDO_PASSWORD}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH}"
|
||||
DEPLOY_NAME="${DEPLOY_NAME}"
|
||||
|
||||
# 检查部署目录是否存在
|
||||
if [ ! -d "${DEPLOY_PATH}" ]; then
|
||||
echo "错误:部署目录 ${DEPLOY_PATH} 不存在,请检查路径是否正确"
|
||||
if [ ! -d "\${DEPLOY_PATH}" ]; then
|
||||
echo "错误:部署目录 \${DEPLOY_PATH} 不存在,请检查路径是否正确"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否有权限写入(尝试创建测试文件)
|
||||
if ! touch "${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
|
||||
echo "提示:没有直接写入权限,将使用 sudo 执行操作"
|
||||
USE_SUDO=1
|
||||
# 检查是否有权限写入,若无则尝试免密 sudo(sudo -n)
|
||||
SUDO_CMD=""
|
||||
if ! touch "\${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
|
||||
if sudo -n true 2>/dev/null; then
|
||||
echo "提示:没有直接写入权限,使用 sudo -n 执行部署操作"
|
||||
SUDO_CMD="sudo -n"
|
||||
else
|
||||
echo "错误:没有写入权限,且 sudo 需要密码(非交互部署无法输入)"
|
||||
echo "请执行以下任一方案后重试:"
|
||||
echo " 1) 将部署目录改为当前用户可写目录(例如 /home/\$USER/www)"
|
||||
echo " 2) 为当前用户配置免密 sudo(NOPASSWD)"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
rm -f "${DEPLOY_PATH}/.deploy_test"
|
||||
USE_SUDO=0
|
||||
rm -f "\${DEPLOY_PATH}/.deploy_test"
|
||||
echo "提示:检测到部署目录可直接写入"
|
||||
fi
|
||||
|
||||
# 备份旧版本(如果存在)
|
||||
if [ -d "${DEPLOY_PATH}/${DEPLOY_NAME}" ]; then
|
||||
if [ -d "\${DEPLOY_PATH}/\${DEPLOY_NAME}" ]; then
|
||||
echo "备份旧版本..."
|
||||
if [ "$USE_SUDO" = "1" ]; then
|
||||
echo "${SUDO_PASSWORD}" | sudo -S rm -rf "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" 2>/dev/null || true
|
||||
echo "${SUDO_PASSWORD}" | sudo -S mv "${DEPLOY_PATH}/${DEPLOY_NAME}" "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" || { echo "错误:备份失败,权限不足"; exit 1; }
|
||||
else
|
||||
rm -rf "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" 2>/dev/null || true
|
||||
mv "${DEPLOY_PATH}/${DEPLOY_NAME}" "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" || { echo "错误:备份失败"; exit 1; }
|
||||
fi
|
||||
\$SUDO_CMD rm -rf "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" 2>/dev/null || true
|
||||
\$SUDO_CMD mv "\${DEPLOY_PATH}/\${DEPLOY_NAME}" "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" || { echo "错误:备份失败,权限不足"; exit 1; }
|
||||
fi
|
||||
|
||||
# 移动新版本到部署目录(覆盖现有目录)
|
||||
if [ -d ~/${DEPLOY_NAME} ]; then
|
||||
if [ -d ~/\${DEPLOY_NAME} ]; then
|
||||
echo "移动新版本到部署目录..."
|
||||
if [ "$USE_SUDO" = "1" ]; then
|
||||
echo "${SUDO_PASSWORD}" | sudo -S mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
|
||||
else
|
||||
mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败"; exit 1; }
|
||||
fi
|
||||
\$SUDO_CMD mv ~/\${DEPLOY_NAME} "\${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
|
||||
echo "部署完成!"
|
||||
else
|
||||
echo "错误:找不到 ~/${DEPLOY_NAME} 目录"
|
||||
echo "错误:找不到 ~/\${DEPLOY_NAME} 目录"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 重新加载 nginx(如果配置了)
|
||||
if command -v nginx &> /dev/null; then
|
||||
echo "重新加载 nginx..."
|
||||
echo "${SUDO_PASSWORD}" | sudo -S nginx -t && echo "${SUDO_PASSWORD}" | sudo -S nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||
if [ -n "\$SUDO_CMD" ]; then
|
||||
\$SUDO_CMD nginx -t && \$SUDO_CMD nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||
else
|
||||
nginx -t && nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||
fi
|
||||
fi
|
||||
SSH_EOF
|
||||
)
|
||||
|
||||
@@ -1,974 +0,0 @@
|
||||
# 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">
|
||||
© {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"
|
||||
```
|
||||
@@ -1,856 +0,0 @@
|
||||
# Workspace Improvements Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Polish the workspace by removing logo/login button clutter, adding a history toggle, enforcing mandatory login after 3 guest uploads, and adding email verification code to the registration flow.
|
||||
|
||||
**Architecture:** Five independent changes across the workspace layer. History toggle state lives in `WorkspacePage` and is passed into `LeftSidebar`. Email verification adds a two-step flow inside `AuthModal` only — no new files needed. All i18n strings go through `translations.ts`.
|
||||
|
||||
**Tech Stack:** React 18, TypeScript, Tailwind CSS (utility classes), Lucide React icons, existing `http` client in `lib/api.ts`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/components/layout/AppNavbar.tsx` | Remove logo icon + text + divider |
|
||||
| `src/components/LeftSidebar.tsx` | Remove login button; add history toggle prop |
|
||||
| `src/pages/WorkspacePage.tsx` | Add `historyEnabled` state; pass to sidebar; force mandatory modal after 3rd upload |
|
||||
| `src/components/AuthModal.tsx` | Add send-code button, countdown, verification code field to signup tab |
|
||||
| `src/lib/authService.ts` | Add `sendEmailCode()` method; update `register()` to send `code` |
|
||||
| `src/types/api.ts` | Add `SendEmailCodeRequest`; add `code` to `RegisterRequest` |
|
||||
| `src/lib/translations.ts` | Add new i18n keys for history toggle, verification code flow |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add i18n strings for new features
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/translations.ts`
|
||||
|
||||
- [ ] **Step 1: Add new keys to both `en` and `zh` auth sections and sidebar section**
|
||||
|
||||
In `src/lib/translations.ts`, find the `en.auth` block and add after `oauthFailed`:
|
||||
```ts
|
||||
sendCode: 'Send Code',
|
||||
resendCode: 'Resend',
|
||||
codeSent: 'Code sent',
|
||||
verificationCode: 'Verification Code',
|
||||
verificationCodePlaceholder: 'Enter 6-digit code',
|
||||
verificationCodeRequired: 'Please enter the verification code.',
|
||||
verificationCodeHint: 'Check your inbox for the 6-digit code.',
|
||||
sendCodeFailed: 'Failed to send verification code, please retry.',
|
||||
```
|
||||
|
||||
In `en.sidebar`, add after `historyHeader`:
|
||||
```ts
|
||||
historyToggle: 'Show History',
|
||||
historyLoginRequired: 'Login to enable history',
|
||||
```
|
||||
|
||||
In `zh.auth`, add after `oauthFailed`:
|
||||
```ts
|
||||
sendCode: '发送验证码',
|
||||
resendCode: '重新发送',
|
||||
codeSent: '验证码已发送',
|
||||
verificationCode: '验证码',
|
||||
verificationCodePlaceholder: '请输入 6 位验证码',
|
||||
verificationCodeRequired: '请输入验证码。',
|
||||
verificationCodeHint: '请查收邮箱中的 6 位验证码。',
|
||||
sendCodeFailed: '发送验证码失败,请重试。',
|
||||
```
|
||||
|
||||
In `zh.sidebar`, add after `historyHeader`:
|
||||
```ts
|
||||
historyToggle: '显示历史',
|
||||
historyLoginRequired: '登录后开启历史记录',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
```bash
|
||||
git add src/lib/translations.ts
|
||||
git commit -m "feat: add i18n keys for history toggle and email verification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Remove logo from AppNavbar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/layout/AppNavbar.tsx:38-50`
|
||||
|
||||
- [ ] **Step 1: Remove logo image, text, and divider — keep only the Home icon link**
|
||||
|
||||
Replace the entire `{/* Left: Logo + Home link */}` div (lines 38–51) with:
|
||||
```tsx
|
||||
{/* Left: Home link */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-1.5 px-2 py-1 text-ink-muted hover:text-ink-secondary text-xs font-medium transition-colors rounded-md hover:bg-cream-200/60"
|
||||
>
|
||||
<Home size={13} />
|
||||
<span className="hidden sm:inline">{t.marketing.nav.home}</span>
|
||||
</Link>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify dev server renders correctly**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Navigate to `/app`. Confirm the navbar no longer shows the TexPixel icon or text, only the Home link on the left.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add src/components/layout/AppNavbar.tsx
|
||||
git commit -m "feat: remove logo from workspace navbar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Remove login button from LeftSidebar and add history toggle prop
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/LeftSidebar.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `historyEnabled` and `onToggleHistory` to the props interface**
|
||||
|
||||
Replace the existing `LeftSidebarProps` interface with:
|
||||
```ts
|
||||
interface LeftSidebarProps {
|
||||
files: FileRecord[];
|
||||
selectedFileId: string | null;
|
||||
onFileSelect: (fileId: string) => void;
|
||||
onUploadClick: () => void;
|
||||
canUploadAnonymously: boolean;
|
||||
onRequireAuth: () => void;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onUploadFiles: (files: File[]) => void;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
historyEnabled: boolean;
|
||||
onToggleHistory: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Destructure the two new props in the function signature**
|
||||
|
||||
Replace the destructuring block (the function params) to add `historyEnabled` and `onToggleHistory`:
|
||||
```ts
|
||||
export default function LeftSidebar({
|
||||
files,
|
||||
selectedFileId,
|
||||
onFileSelect,
|
||||
onUploadClick,
|
||||
canUploadAnonymously,
|
||||
onRequireAuth,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onUploadFiles,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
historyEnabled,
|
||||
onToggleHistory,
|
||||
}: LeftSidebarProps) {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `Toggle` icon to lucide imports**
|
||||
|
||||
Change the import line at the top from:
|
||||
```ts
|
||||
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
import { Upload, LogOut, FileText, Clock, ChevronLeft, ChevronRight, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
|
||||
```
|
||||
(Remove `LogIn`, `History` — no longer used in expanded view)
|
||||
|
||||
- [ ] **Step 4: Remove the `showAuthModal` state and the `AuthModal` import/usage from LeftSidebar**
|
||||
|
||||
Remove these lines near the top of the function body:
|
||||
```ts
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
```
|
||||
and the `useEffect` that sets `showAuthModal` to false on auth:
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
setShowAuthModal(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
```
|
||||
Also remove `import AuthModal from './AuthModal';` at the top of the file, and at the bottom remove the `{showAuthModal && <AuthModal onClose={() => setShowAuthModal(false)} />}` JSX.
|
||||
|
||||
- [ ] **Step 5: Replace the history header section with a toggle switch**
|
||||
|
||||
Find the `{/* Middle Area: History */}` block. Replace the header div (the one with `Clock` icon and `historyHeader` text) with:
|
||||
```tsx
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} />
|
||||
<span>{t.sidebar.historyHeader}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleHistory}
|
||||
className={`relative w-8 h-4 rounded-full transition-colors duration-200 focus:outline-none ${
|
||||
historyEnabled ? 'bg-blue-500' : 'bg-gray-300'
|
||||
}`}
|
||||
title={t.sidebar.historyToggle}
|
||||
aria-pressed={historyEnabled}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full shadow transition-transform duration-200 ${
|
||||
historyEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Gate the history list behind `historyEnabled`**
|
||||
|
||||
Replace the entire `<div ref={listRef} ...>` scrollable list with:
|
||||
```tsx
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto space-y-1 pr-2 -mr-2 custom-scrollbar"
|
||||
>
|
||||
{!historyEnabled ? (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
||||
{t.sidebar.historyToggle}
|
||||
</div>
|
||||
) : !user ? (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
||||
{t.sidebar.historyLoginRequired}
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
||||
{t.sidebar.noHistory}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.id}
|
||||
onClick={() => onFileSelect(file.id)}
|
||||
className={`w-full p-3 rounded-lg text-left transition-all border group relative ${selectedFileId === file.id
|
||||
? 'bg-blue-50 border-blue-200 shadow-sm'
|
||||
: 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${selectedFileId === file.id ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
|
||||
<FileText size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${selectedFileId === file.id ? 'text-blue-900' : 'text-gray-700'}`}>
|
||||
{file.filename}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(file.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${file.status === 'completed' ? 'bg-green-500' :
|
||||
file.status === 'processing' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center py-3 text-gray-400">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<span className="ml-2 text-xs">{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && files.length > 0 && (
|
||||
<div className="text-center py-3 text-xs text-gray-400">
|
||||
{t.sidebar.noMore}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Replace the bottom user/login area — remove login button, keep only logged-in user view**
|
||||
|
||||
Replace the entire `{/* Bottom Area: User/Login */}` div with:
|
||||
```tsx
|
||||
{/* Bottom Area: User info (only shown when logged in) */}
|
||||
{user && (
|
||||
<div className="p-4 border-t border-gray-100 bg-gray-50/30">
|
||||
<div className="flex items-center gap-3 p-2 rounded-lg bg-white border border-gray-100 shadow-sm">
|
||||
<div className="w-8 h-8 bg-gray-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 12C14.21 12 16 10.21 16 8C16 5.79 14.21 4 12 4C9.79 4 8 5.79 8 8C8 10.21 9.79 12 12 12ZM12 14C9.33 14 4 15.34 4 18V20H20V18C20 15.34 14.67 14 12 14Z" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{displayName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
||||
title={t.common.logout}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Fix collapsed view — remove LogIn icon button**
|
||||
|
||||
In the `if (isCollapsed)` return block, remove the bottom `<button>` that shows the `LogIn` icon:
|
||||
```tsx
|
||||
<button
|
||||
onClick={() => !user && setShowAuthModal(true)}
|
||||
className="p-3 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors mt-auto"
|
||||
title={user ? 'Signed In' : t.common.login}
|
||||
>
|
||||
<LogIn size={20} />
|
||||
</button>
|
||||
```
|
||||
Replace with nothing (delete that button entirely). The `isAuthenticated` import can stay for now.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
```bash
|
||||
git add src/components/LeftSidebar.tsx
|
||||
git commit -m "feat: remove login button from sidebar, add history toggle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire history toggle state and mandatory auth in WorkspacePage
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/WorkspacePage.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `historyEnabled` state**
|
||||
|
||||
After the existing `const [loadingMore, setLoadingMore] = useState(false);` line, add:
|
||||
```ts
|
||||
const [historyEnabled, setHistoryEnabled] = useState(false);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `handleToggleHistory` callback**
|
||||
|
||||
After the `openAuthModal` callback, add:
|
||||
```ts
|
||||
const handleToggleHistory = useCallback(() => {
|
||||
if (!historyEnabled) {
|
||||
// Turning on
|
||||
if (!user) {
|
||||
openAuthModal();
|
||||
return;
|
||||
}
|
||||
setHistoryEnabled(true);
|
||||
if (!hasLoadedFiles.current) {
|
||||
hasLoadedFiles.current = true;
|
||||
loadFiles();
|
||||
}
|
||||
} else {
|
||||
setHistoryEnabled(false);
|
||||
}
|
||||
}, [historyEnabled, user, openAuthModal]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove auto-load on auth — history is now opt-in**
|
||||
|
||||
Find the `useEffect` that auto-calls `loadFiles()` when `user` becomes available:
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!initializing && user && !hasLoadedFiles.current) {
|
||||
hasLoadedFiles.current = true;
|
||||
loadFiles();
|
||||
}
|
||||
if (!user) {
|
||||
hasLoadedFiles.current = false;
|
||||
setFiles([]);
|
||||
setSelectedFileId(null);
|
||||
setCurrentPage(1);
|
||||
setHasMore(false);
|
||||
}
|
||||
}, [initializing, user]);
|
||||
```
|
||||
|
||||
Replace with (keep the reset on logout, remove the auto-load):
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
hasLoadedFiles.current = false;
|
||||
setHistoryEnabled(false);
|
||||
setFiles([]);
|
||||
setSelectedFileId(null);
|
||||
setCurrentPage(1);
|
||||
setHasMore(false);
|
||||
}
|
||||
}, [user]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Make the auth modal mandatory after 3rd upload**
|
||||
|
||||
Add a new state after `showAuthModal`:
|
||||
```ts
|
||||
const [authModalMandatory, setAuthModalMandatory] = useState(false);
|
||||
```
|
||||
|
||||
Change `openAuthModal` to accept an optional `mandatory` parameter:
|
||||
```ts
|
||||
const openAuthModal = useCallback((mandatory = false) => {
|
||||
setAuthModalMandatory(mandatory);
|
||||
setShowAuthModal(true);
|
||||
}, []);
|
||||
```
|
||||
|
||||
In `handleUpload`, after `incrementGuestUsage()`, add a mandatory modal trigger when the new count hits the limit:
|
||||
```ts
|
||||
if (!user && successfulUploads > 0) {
|
||||
incrementGuestUsage();
|
||||
// Force login after hitting the limit
|
||||
setGuestUsageCount(prev => {
|
||||
const next = prev + 1;
|
||||
if (next >= GUEST_USAGE_LIMIT) {
|
||||
openAuthModal(true);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Wait — `incrementGuestUsage` already increments. We need to check the new count after increment. Replace the `if (!user && successfulUploads > 0)` block at the end of `handleUpload` with:
|
||||
```ts
|
||||
if (!user && successfulUploads > 0) {
|
||||
const nextCount = guestUsageCount + successfulUploads;
|
||||
const newCount = Math.min(nextCount, GUEST_USAGE_LIMIT + 10);
|
||||
localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(newCount));
|
||||
setGuestUsageCount(newCount);
|
||||
if (newCount >= GUEST_USAGE_LIMIT) {
|
||||
openAuthModal(true);
|
||||
}
|
||||
}
|
||||
```
|
||||
And remove the separate `incrementGuestUsage` call entirely (delete the `incrementGuestUsage` callback too).
|
||||
|
||||
- [ ] **Step 5: Update the two places that check upload limit to pass `mandatory=false` (keep non-mandatory for pre-upload checks)**
|
||||
|
||||
The `onUploadClick` handler and paste handler already call `openAuthModal()` with no arg (defaults to `false`) — that stays as-is.
|
||||
|
||||
- [ ] **Step 6: Update AuthModal JSX to pass `mandatory` prop and update close handler**
|
||||
|
||||
Find `{showAuthModal && (<AuthModal onClose={() => setShowAuthModal(false)} />)}` and replace with:
|
||||
```tsx
|
||||
{showAuthModal && (
|
||||
<AuthModal
|
||||
onClose={() => { setShowAuthModal(false); setAuthModalMandatory(false); }}
|
||||
mandatory={authModalMandatory}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Pass `historyEnabled` and `onToggleHistory` to LeftSidebar**
|
||||
|
||||
Find the `<LeftSidebar` JSX and add the two new props:
|
||||
```tsx
|
||||
historyEnabled={historyEnabled}
|
||||
onToggleHistory={handleToggleHistory}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: After login via mandatory modal, auto-enable history**
|
||||
|
||||
In the `useEffect` that watches `user`, add history auto-enable when user logs in while `historyEnabled` is still false — actually simpler to auto-enable history when user is set and they just came from a mandatory flow. Instead, just handle this in the `useEffect` that watches user change after modal closes:
|
||||
|
||||
Replace the logout-only effect from Step 3 with:
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (user && !hasLoadedFiles.current && historyEnabled) {
|
||||
// user logged in while history was already toggled on
|
||||
hasLoadedFiles.current = true;
|
||||
loadFiles();
|
||||
}
|
||||
if (!user) {
|
||||
hasLoadedFiles.current = false;
|
||||
setHistoryEnabled(false);
|
||||
setFiles([]);
|
||||
setSelectedFileId(null);
|
||||
setCurrentPage(1);
|
||||
setHasMore(false);
|
||||
}
|
||||
}, [user]);
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
```bash
|
||||
git add src/pages/WorkspacePage.tsx
|
||||
git commit -m "feat: history toggle state, mandatory auth after 3 guest uploads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add email verification to API types and authService
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/types/api.ts`
|
||||
- Modify: `src/lib/authService.ts`
|
||||
|
||||
- [ ] **Step 1: Update `RegisterRequest` and add `SendEmailCodeRequest` in `src/types/api.ts`**
|
||||
|
||||
Find the existing `RegisterRequest` interface:
|
||||
```ts
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface SendEmailCodeRequest {
|
||||
email: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `sendEmailCode()` to `authService` in `src/lib/authService.ts`**
|
||||
|
||||
Add the import of `SendEmailCodeRequest` — it will be used below. Then add a new method inside `export const authService = { ... }` after the `login` method:
|
||||
```ts
|
||||
async sendEmailCode(email: string): Promise<void> {
|
||||
await http.post<null>('/user/email/code', { email } satisfies SendEmailCodeRequest, { skipAuth: true });
|
||||
},
|
||||
```
|
||||
|
||||
Also update the `register` method signature to accept `code`:
|
||||
```ts
|
||||
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
|
||||
|
||||
if (!response.data) {
|
||||
throw new ApiError(-1, '注册失败,请重试');
|
||||
}
|
||||
|
||||
return buildSession(response.data, credentials.email);
|
||||
},
|
||||
```
|
||||
(Signature stays the same — just ensuring `credentials` now includes `code` via the updated type.)
|
||||
|
||||
- [ ] **Step 3: Update the import in `authService.ts` to include `SendEmailCodeRequest`**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
import type {
|
||||
AuthData,
|
||||
GoogleAuthUrlData,
|
||||
GoogleOAuthCallbackRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
UserInfoData,
|
||||
UserInfo,
|
||||
} from '../types/api';
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
import type {
|
||||
AuthData,
|
||||
GoogleAuthUrlData,
|
||||
GoogleOAuthCallbackRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
SendEmailCodeRequest,
|
||||
UserInfoData,
|
||||
UserInfo,
|
||||
} from '../types/api';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add src/types/api.ts src/lib/authService.ts
|
||||
git commit -m "feat: add sendEmailCode API and code field to RegisterRequest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Update AuthContext to pass `code` through signUp
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/contexts/AuthContext.tsx`
|
||||
|
||||
- [ ] **Step 1: Update `signUp` to accept and forward `code`**
|
||||
|
||||
Find the `signUp` function/action in `AuthContext.tsx`. It currently calls `authService.register({ email, password })`. Update the `signUp` signature to accept a third `code` parameter and pass it:
|
||||
```ts
|
||||
signUp: async (email: string, password: string, code: string) => { ... }
|
||||
```
|
||||
Inside, change:
|
||||
```ts
|
||||
await authService.register({ email, password, code });
|
||||
```
|
||||
|
||||
Also update the `AuthContextValue` interface (or wherever `signUp` type is declared) to:
|
||||
```ts
|
||||
signUp: (email: string, password: string, code: string) => Promise<{ error: string | null }>;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
```bash
|
||||
git add src/contexts/AuthContext.tsx
|
||||
git commit -m "feat: thread verification code through AuthContext signUp"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Add email verification UI to AuthModal
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/AuthModal.tsx`
|
||||
|
||||
- [ ] **Step 1: Add verification code state variables**
|
||||
|
||||
After the existing `useState` declarations in `AuthModal`, add:
|
||||
```ts
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [codeCountdown, setCodeCountdown] = useState(0);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const countdownRef = useRef<NodeJS.Timeout | null>(null);
|
||||
```
|
||||
|
||||
Add `useRef` to the React import if not already there.
|
||||
|
||||
Also import `authService` at the top:
|
||||
```ts
|
||||
import { authService } from '../lib/authService';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `sendCode` handler**
|
||||
|
||||
After the `handleGoogleOAuth` function, add:
|
||||
```ts
|
||||
const handleSendCode = async () => {
|
||||
const normalizedEmail = email.trim();
|
||||
if (!normalizedEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
||||
setFieldErrors(prev => ({ ...prev, email: t.auth.emailInvalid }));
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
await authService.sendEmailCode(normalizedEmail);
|
||||
setCodeSent(true);
|
||||
setCodeCountdown(60);
|
||||
countdownRef.current = setInterval(() => {
|
||||
setCodeCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(countdownRef.current!);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch {
|
||||
setLocalError(t.auth.sendCodeFailed);
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Add a cleanup `useEffect` for the countdown interval:
|
||||
```ts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (countdownRef.current) clearInterval(countdownRef.current);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `handleSubmit` to validate code and pass it to `signUp`**
|
||||
|
||||
In the `handleSubmit` function, inside the `if (mode === 'signup')` validation block, add a check for the verification code:
|
||||
```ts
|
||||
if (!verificationCode.trim()) {
|
||||
nextFieldErrors.verificationCode = t.auth.verificationCodeRequired;
|
||||
}
|
||||
```
|
||||
|
||||
Update `fieldErrors` type to include `verificationCode`:
|
||||
```ts
|
||||
const [fieldErrors, setFieldErrors] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
verificationCode?: string;
|
||||
}>({});
|
||||
```
|
||||
|
||||
Update the `signUp` call to pass the code:
|
||||
```ts
|
||||
const result = mode === 'signup'
|
||||
? await signUp(normalizedEmail, password, verificationCode.trim())
|
||||
: await signIn(normalizedEmail, password);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add Send Code button inline with the email field (signup mode only)**
|
||||
|
||||
In the JSX, find the email `<div style={s.fieldGroup}>` block. Replace it with a version that adds the send-code button when in signup mode:
|
||||
```tsx
|
||||
<div style={s.fieldGroup}>
|
||||
<label htmlFor="auth-email" style={s.label}>{t.auth.email}</label>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<input
|
||||
id="auth-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }));
|
||||
}}
|
||||
style={fieldErrors.email ? { ...s.input, ...s.inputError } : s.input}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{fieldErrors.email && <p style={s.fieldError}>{fieldErrors.email}</p>}
|
||||
{mode === 'signup' && <p style={s.fieldHint}>{t.auth.emailHint}</p>}
|
||||
</div>
|
||||
{mode === 'signup' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={isBusy || sendingCode || codeCountdown > 0}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '42px',
|
||||
padding: '0 12px',
|
||||
background: codeCountdown > 0 ? '#F5DDD0' : '#C8622A',
|
||||
color: codeCountdown > 0 ? '#AA9685' : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
cursor: codeCountdown > 0 || sendingCode ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{codeCountdown > 0
|
||||
? `${codeCountdown}s`
|
||||
: codeSent
|
||||
? t.auth.resendCode
|
||||
: t.auth.sendCode}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the verification code input field (signup mode, after confirm password)**
|
||||
|
||||
After the confirm-password `{mode === 'signup' && (...)}` block, add:
|
||||
```tsx
|
||||
{mode === 'signup' && (
|
||||
<div style={s.fieldGroup}>
|
||||
<label htmlFor="auth-code" style={s.label}>{t.auth.verificationCode}</label>
|
||||
<input
|
||||
id="auth-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={verificationCode}
|
||||
onChange={(e) => {
|
||||
setVerificationCode(e.target.value.replace(/\D/g, ''));
|
||||
if (fieldErrors.verificationCode) setFieldErrors(prev => ({ ...prev, verificationCode: undefined }));
|
||||
}}
|
||||
style={fieldErrors.verificationCode ? { ...s.input, ...s.inputError } : s.input}
|
||||
placeholder={t.auth.verificationCodePlaceholder}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{fieldErrors.verificationCode
|
||||
? <p style={s.fieldError}>{fieldErrors.verificationCode}</p>
|
||||
: <p style={s.fieldHint}>{t.auth.verificationCodeHint}</p>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Reset code state when switching to signin tab**
|
||||
|
||||
In the `setMode('signin')` onClick handler, add resets:
|
||||
```ts
|
||||
onClick={() => {
|
||||
setMode('signin');
|
||||
setFieldErrors({});
|
||||
setLocalError('');
|
||||
setVerificationCode('');
|
||||
setCodeSent(false);
|
||||
setCodeCountdown(0);
|
||||
if (countdownRef.current) clearInterval(countdownRef.current);
|
||||
}}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify in browser — register flow should now require sending code first**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Open `/app`, click register. Confirm:
|
||||
1. Email field has "Send Code" button
|
||||
2. Clicking it (with valid email) triggers send and starts countdown
|
||||
3. Verification code field appears below confirm-password
|
||||
4. Submitting without code shows validation error
|
||||
5. Login tab does not show the code field
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
```bash
|
||||
git add src/components/AuthModal.tsx
|
||||
git commit -m "feat: add email verification code step to registration flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Final smoke test
|
||||
|
||||
- [ ] **Step 1: Run type check**
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 2: Run unit tests**
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
Expected: all pass (existing tests should still pass)
|
||||
|
||||
- [ ] **Step 3: Manual end-to-end check**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Verify:
|
||||
1. Workspace navbar has no logo — only Home link on left
|
||||
2. Sidebar bottom shows no login button when logged out
|
||||
3. History toggle is off by default, shows placeholder text
|
||||
4. Toggling history on while logged out opens auth modal
|
||||
5. After 3 guest uploads, mandatory auth modal appears (no close button)
|
||||
6. Register tab: Send Code button appears, countdown works, code field required
|
||||
7. Sign-in tab: no code field visible
|
||||
8. Logged-in user: history toggle on loads history; logout resets toggle to off
|
||||
@@ -1,75 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,129 +0,0 @@
|
||||
# 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 10–1593) 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`
|
||||
@@ -1,57 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const jwtPayload = Buffer.from(
|
||||
JSON.stringify({ user_id: 7, email: 'user@example.com', exp: 1999999999, iat: 1111111 })
|
||||
)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
const token = `header.${jwtPayload}.sig`;
|
||||
|
||||
test('email login should authenticate and display user email', async ({ page }) => {
|
||||
await page.route('**/user/login', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
request_id: 'req_login',
|
||||
code: 200,
|
||||
message: 'ok',
|
||||
data: {
|
||||
token,
|
||||
expires_at: 1999999999,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/task/list**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
request_id: 'req_tasks',
|
||||
code: 200,
|
||||
message: 'ok',
|
||||
data: {
|
||||
task_list: [],
|
||||
total: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const loginButton = page.getByRole('button', { name: /Login|登录/ }).first();
|
||||
await loginButton.click();
|
||||
|
||||
await page.fill('#auth-email', 'user@example.com');
|
||||
await page.fill('#auth-password', '123456');
|
||||
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText('user@example.com')).toBeVisible();
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const jwtPayload = Buffer.from(
|
||||
JSON.stringify({ user_id: 9, email: 'oauth@example.com', exp: 1999999999, iat: 1111111 })
|
||||
)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
const token = `header.${jwtPayload}.sig`;
|
||||
|
||||
test('google oauth callback with valid state should complete login', async ({ page }) => {
|
||||
await page.route('**/user/oauth/google/url**', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const state = url.searchParams.get('state') ?? '';
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
request_id: 'req_oauth_url',
|
||||
code: 200,
|
||||
message: 'ok',
|
||||
data: {
|
||||
auth_url: `http://127.0.0.1:4173/auth/google/callback?code=oauth_code&state=${state}`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/user/oauth/google/callback', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
request_id: 'req_oauth_callback',
|
||||
code: 200,
|
||||
message: 'ok',
|
||||
data: {
|
||||
token,
|
||||
expires_at: 1999999999,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/task/list**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
request_id: 'req_tasks',
|
||||
code: 200,
|
||||
message: 'ok',
|
||||
data: {
|
||||
task_list: [],
|
||||
total: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /Login|登录/ }).first().click();
|
||||
await page.getByRole('button', { name: /Google/ }).click();
|
||||
|
||||
await expect(page.getByText('oauth@example.com')).toBeVisible();
|
||||
});
|
||||
|
||||
test('google oauth callback with invalid state should show error', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.setItem('texpixel_oauth_state', 'expected_state');
|
||||
});
|
||||
|
||||
await page.goto('/auth/google/callback?code=fake_code&state=wrong_state');
|
||||
await expect(page.getByText('OAuth state 校验失败')).toBeVisible();
|
||||
});
|
||||
109
index.html
@@ -3,98 +3,83 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- 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/" />
|
||||
<link rel="alternate" hreflang="en" href="https://texpixel.com/en/" />
|
||||
<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 -->
|
||||
<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" />
|
||||
<!-- 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" />
|
||||
<meta name="author" content="TexPixel Team" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- 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." />
|
||||
<!-- 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." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://texpixel.com/" />
|
||||
<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: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:site_name" content="TexPixel" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<!-- Twitter Card Meta Tags - Bilingual -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<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: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:site" content="@TexPixel" />
|
||||
|
||||
<!-- Baidu Verification -->
|
||||
<meta name="baidu-site-verification" content="codeva-8zU93DeGgH" />
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<!-- 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",
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "TexPixel",
|
||||
"url": "https://texpixel.com/"
|
||||
"url": "https://texpixel.com/",
|
||||
"inLanguage": ["zh-CN", "en"],
|
||||
"description": "Formula recognition tool for converting images and PDFs into LaTeX, MathML, and Markdown."
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "TexPixel",
|
||||
"applicationCategory": "BusinessApplication",
|
||||
"operatingSystem": "Web",
|
||||
"url": "https://texpixel.com/",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"description": "Online OCR and formula recognition for printed and handwritten mathematical expressions."
|
||||
}
|
||||
}
|
||||
</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';
|
||||
if (isZh) {
|
||||
document.title = 'TexPixel - AI 数学公式识别工具 | LaTeX、MathML OCR';
|
||||
document.querySelector('meta[name="description"]').setAttribute('content',
|
||||
'免费 AI 数学公式识别工具,支持手写和印刷体公式识别,一键将图片或 PDF 中的数学公式转换为 LaTeX、MathML 和 Markdown 格式。');
|
||||
|
||||
// Update page title based on language
|
||||
if (!isZh) {
|
||||
document.title = '⚡️ TexPixel - Formula Recognition Tool';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
3238
package-lock.json
generated
28
package.json
@@ -4,15 +4,11 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:dev": "VITE_ENV=development vite build",
|
||||
"build:prod": "VITE_ENV=production vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
@@ -21,17 +17,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",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
@@ -41,9 +34,6 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
@@ -52,18 +42,10 @@
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"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"
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
167
public/en/index.html
Normal file
@@ -0,0 +1,167 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TexPixel - Formula Recognition Tool for LaTeX, MathML, and Markdown</title>
|
||||
<meta name="description" content="TexPixel converts printed and handwritten math formulas from images and PDFs into LaTeX, MathML, and Markdown." />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="author" content="TexPixel Team" />
|
||||
<link rel="canonical" href="https://texpixel.com/en/" />
|
||||
<link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/" />
|
||||
<link rel="alternate" hreflang="en" href="https://texpixel.com/en/" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://texpixel.com/" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="TexPixel" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:locale:alternate" content="zh_CN" />
|
||||
<meta property="og:url" content="https://texpixel.com/en/" />
|
||||
<meta property="og:title" content="TexPixel - Formula Recognition Tool" />
|
||||
<meta property="og:description" content="Extract formulas from images and PDFs to editable LaTeX, MathML, and Markdown." />
|
||||
<meta property="og:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@TexPixel" />
|
||||
<meta name="twitter:title" content="TexPixel - Formula Recognition Tool" />
|
||||
<meta name="twitter:description" content="Convert mathematical content from images and PDFs into LaTeX, MathML, and Markdown." />
|
||||
<meta name="twitter:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "TexPixel English",
|
||||
"url": "https://texpixel.com/en/",
|
||||
"inLanguage": "en",
|
||||
"description": "Formula recognition landing page in English."
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What can TexPixel recognize?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "TexPixel recognizes printed and handwritten mathematical formulas from images and PDF pages."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Which output formats are supported?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "TexPixel supports LaTeX, MathML, and Markdown outputs for downstream editing and publishing."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Do I need to install software?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "No installation is required. TexPixel works as a web application."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f8fc;
|
||||
--text: #111827;
|
||||
--muted: #4b5563;
|
||||
--accent: #0f766e;
|
||||
--accent-hover: #0d5f59;
|
||||
--card: #ffffff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--text);
|
||||
background: radial-gradient(circle at 20% 20%, #eefbf8 0%, var(--bg) 40%, #f6f8ff 100%);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 20px 64px;
|
||||
}
|
||||
.hero, .section {
|
||||
background: var(--card);
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1.2;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
}
|
||||
p { margin: 0 0 14px; color: var(--muted); }
|
||||
ul { margin: 0; padding-left: 20px; color: var(--muted); }
|
||||
li { margin-bottom: 8px; }
|
||||
.cta {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.cta:hover { background: var(--accent-hover); }
|
||||
.small { font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="hero">
|
||||
<h1>Formula Recognition for Real Math Workflows</h1>
|
||||
<p>TexPixel converts formulas from screenshots, photos, and PDF pages into editable text formats for researchers, students, and engineering teams.</p>
|
||||
<a class="cta" id="open-app" href="/">Open TexPixel App</a>
|
||||
<p class="small">The app opens at the main product URL and defaults to English for this entry point.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Core Capabilities</h2>
|
||||
<ul>
|
||||
<li>Recognize printed and handwritten formulas from image or PDF input.</li>
|
||||
<li>Export to LaTeX for papers, MathML for web workflows, and Markdown for docs.</li>
|
||||
<li>Use the browser-based workflow without local software installation.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>FAQ</h2>
|
||||
<p><strong>Is TexPixel browser-based?</strong><br />Yes. You can upload files and get output directly in the web app.</p>
|
||||
<p><strong>What content type works best?</strong><br />Clean scans and high-contrast screenshots improve recognition quality.</p>
|
||||
<p><strong>Can I reuse output in technical documents?</strong><br />Yes. LaTeX and Markdown outputs are intended for editing and reuse.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var openApp = document.getElementById('open-app');
|
||||
if (!openApp) return;
|
||||
openApp.addEventListener('click', function () {
|
||||
try {
|
||||
localStorage.setItem('language', 'en');
|
||||
} catch (err) {
|
||||
// Keep navigation working even if storage is unavailable.
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,68 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 84 KiB |
14
public/llms.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# TexPixel
|
||||
|
||||
TexPixel is a web tool for converting images and PDFs into editable mathematical formats such as LaTeX, MathML, and Markdown.
|
||||
|
||||
Canonical URL: https://texpixel.com/
|
||||
Primary languages: zh-CN, en
|
||||
|
||||
## Preferred page for citation
|
||||
- https://texpixel.com/
|
||||
- https://texpixel.com/en/
|
||||
|
||||
## Crawl guidance
|
||||
- Public product content is allowed for indexing.
|
||||
- Avoid API and authenticated/private areas.
|
||||
@@ -1,5 +1,7 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /app
|
||||
Disallow: /api/
|
||||
Disallow: /auth/
|
||||
Disallow: /admin/
|
||||
|
||||
Sitemap: https://texpixel.com/sitemap.xml
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,37 +1,15 @@
|
||||
<?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">
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://texpixel.com/</loc>
|
||||
<lastmod>2026-03-25</lastmod>
|
||||
<lastmod>2026-02-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>
|
||||
<loc>https://texpixel.com/en/</loc>
|
||||
<lastmod>2026-02-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>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,29 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,116 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"frontend-design": {
|
||||
"source": "anthropics/skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
|
||||
}
|
||||
}
|
||||
}
|
||||
536
src/App.tsx
@@ -1,5 +1,535 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
export default function App() {
|
||||
return <Navigate to="/" replace />;
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
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 [loading, setLoading] = useState(false);
|
||||
|
||||
// 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;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}, [user, showUploadModal]);
|
||||
|
||||
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[]) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
} 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={() => setShowUploadModal(true)}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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;
|
||||
|
||||
@@ -1,526 +1,111 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { 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;
|
||||
mandatory?: boolean;
|
||||
}
|
||||
|
||||
export default function AuthModal({ onClose, mandatory = false }: AuthModalProps) {
|
||||
const { signIn, signUp, beginGoogleOAuth, authPhase, authError } = useAuth();
|
||||
export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
const { signIn, signUp } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
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),
|
||||
[authPhase]
|
||||
);
|
||||
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLocalError('');
|
||||
const nextFieldErrors: { email?: string; password?: string; confirmPassword?: string; verificationCode?: string } = {};
|
||||
const normalizedEmail = email.trim();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
if (!normalizedEmail) {
|
||||
nextFieldErrors.email = t.auth.emailRequired;
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
||||
nextFieldErrors.email = t.auth.emailInvalid;
|
||||
}
|
||||
try {
|
||||
const { error } = isSignUp
|
||||
? await signUp(email, password)
|
||||
: await signIn(email, password);
|
||||
|
||||
if (!password) {
|
||||
nextFieldErrors.password = t.auth.passwordRequired;
|
||||
}
|
||||
|
||||
if (mode === 'signup') {
|
||||
if (password && password.length < 6) {
|
||||
nextFieldErrors.password = t.auth.passwordHint;
|
||||
}
|
||||
|
||||
if (!verificationCode.trim()) {
|
||||
nextFieldErrors.verificationCode = t.auth.verificationCodeRequired;
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
nextFieldErrors.confirmPassword = t.auth.passwordRequired;
|
||||
} else if (password !== confirmPassword) {
|
||||
nextFieldErrors.confirmPassword = t.auth.passwordMismatch;
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('发生错误,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleOAuth = async () => {
|
||||
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 style={s.overlay}>
|
||||
<div style={s.card}>
|
||||
<div style={s.header}>
|
||||
<h2 style={s.title}>
|
||||
{mode === 'signup' ? t.auth.signUpTitle : t.auth.signInTitle}
|
||||
<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">
|
||||
{isSignUp ? t.auth.signUpTitle : t.auth.signInTitle}
|
||||
</h2>
|
||||
{!mandatory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={s.closeBtn}
|
||||
aria-label="close"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={s.tabs}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('signin');
|
||||
setFieldErrors({});
|
||||
setLocalError('');
|
||||
resetSignupState();
|
||||
}}
|
||||
aria-pressed={mode === 'signin'}
|
||||
disabled={isBusy}
|
||||
style={mode === 'signin' ? s.tabActive : s.tabInactive}
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{t.auth.signIn}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('signup');
|
||||
setFieldErrors({});
|
||||
setLocalError('');
|
||||
}}
|
||||
aria-pressed={mode === 'signup'}
|
||||
disabled={isBusy}
|
||||
style={mode === 'signup' ? s.tabActive : s.tabInactive}
|
||||
>
|
||||
{t.auth.signUp}
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div style={s.fieldGroup}>
|
||||
<label htmlFor="auth-email" style={s.label}>{t.auth.email}</label>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.auth.email}
|
||||
</label>
|
||||
<input
|
||||
id="auth-email"
|
||||
type="email"
|
||||
value={email}
|
||||
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}
|
||||
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"
|
||||
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>
|
||||
|
||||
<div style={s.fieldGroup}>
|
||||
<label htmlFor="auth-password" style={s.label}>{t.auth.password}</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t.auth.password}
|
||||
</label>
|
||||
<input
|
||||
id="auth-password"
|
||||
type="password"
|
||||
value={password}
|
||||
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}
|
||||
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"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={6}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{fieldErrors.password && <p style={s.fieldError}>{fieldErrors.password}</p>}
|
||||
{mode === 'signup' && <p style={s.fieldHint}>{t.auth.passwordHint}</p>}
|
||||
</div>
|
||||
|
||||
{mode === 'signup' && (
|
||||
<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);
|
||||
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 style={s.errorBox}>
|
||||
{t.auth.error}: {localError || authError}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse">
|
||||
{t.auth.error}: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
style={{ ...s.submitBtn, opacity: isBusy ? 0.75 : 1, cursor: isBusy ? 'wait' : 'pointer' }}
|
||||
disabled={loading}
|
||||
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"
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
|
||||
<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}
|
||||
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"
|
||||
style={{ width: '18px', height: '18px' }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{authPhase === 'oauth_redirecting' ? t.auth.oauthRedirecting : t.auth.continueWithGoogle}
|
||||
{isSignUp ? t.auth.signUp : t.auth.signIn}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => setIsSignUp(!isSignUp)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{isSignUp ? t.auth.hasAccount : t.auth.noAccount}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Upload, FileText, Clock, ChevronLeft, ChevronRight, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
|
||||
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, Settings, History, 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[];
|
||||
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;
|
||||
}
|
||||
|
||||
export default function LeftSidebar({
|
||||
@@ -26,23 +23,21 @@ export default function LeftSidebar({
|
||||
selectedFileId,
|
||||
onFileSelect,
|
||||
onUploadClick,
|
||||
canUploadAnonymously,
|
||||
onRequireAuth,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onUploadFiles,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
historyEnabled,
|
||||
onToggleHistory,
|
||||
}: LeftSidebarProps) {
|
||||
const { user } = useAuth();
|
||||
const { user, signOut } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ... (rest of the logic remains the same)
|
||||
// Handle scroll to load more
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!listRef.current || loadingMore || !hasMore) return;
|
||||
@@ -89,10 +84,6 @@ export default function LeftSidebar({
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user && !canUploadAnonymously) {
|
||||
onRequireAuth();
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
@@ -106,10 +97,6 @@ export default function LeftSidebar({
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!user && !canUploadAnonymously) {
|
||||
onRequireAuth();
|
||||
return;
|
||||
}
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onUploadFiles(Array.from(e.target.files));
|
||||
}
|
||||
@@ -130,20 +117,26 @@ export default function LeftSidebar({
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!user && !canUploadAnonymously) {
|
||||
onRequireAuth();
|
||||
return;
|
||||
}
|
||||
onUploadClick();
|
||||
}}
|
||||
onClick={onUploadClick}
|
||||
className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20 transition-all mb-6"
|
||||
title={t.common.upload}
|
||||
>
|
||||
<Upload size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 w-full flex flex-col items-center gap-4" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -169,15 +162,9 @@ export default function LeftSidebar({
|
||||
<div className="mb-2" id="sidebar-upload-area">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => {
|
||||
if (!user && !canUploadAnonymously) {
|
||||
onRequireAuth();
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 group
|
||||
${isDragging
|
||||
@@ -218,25 +205,9 @@ 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 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 className="flex items-center gap-2 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
|
||||
<Clock size={14} />
|
||||
<span>{t.sidebar.historyHeader}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -244,15 +215,10 @@ export default function LeftSidebar({
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto space-y-1 pr-2 -mr-2 custom-scrollbar"
|
||||
>
|
||||
{!historyEnabled ? (
|
||||
{!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.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}
|
||||
{t.sidebar.pleaseLogin}
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
@@ -307,7 +273,42 @@ 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">{user.email}</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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ 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">
|
||||
<img src="/texpixel-app-icon.svg" alt="TexPixel" className="w-8 h-8" />
|
||||
<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>
|
||||
<span className="text-xl font-bold text-gray-900 tracking-tight">
|
||||
TexPixel
|
||||
</span>
|
||||
|
||||
@@ -91,9 +91,11 @@ export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClos
|
||||
}
|
||||
};
|
||||
|
||||
const backdropClipPath =
|
||||
highlightStyle.top !== undefined
|
||||
? `polygon(
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] pointer-events-none">
|
||||
{/* Backdrop with hole */}
|
||||
<div className="absolute inset-0 bg-black/60 pointer-events-auto" onClick={onClose} style={{
|
||||
clipPath: highlightStyle.top !== undefined ? `polygon(
|
||||
0% 0%, 0% 100%,
|
||||
${highlightStyle.left}px 100%,
|
||||
${highlightStyle.left}px ${highlightStyle.top}px,
|
||||
@@ -102,17 +104,8 @@ export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClos
|
||||
${highlightStyle.left}px ${(highlightStyle.top as number) + (highlightStyle.height as number)}px,
|
||||
${highlightStyle.left}px 100%,
|
||||
100% 100%, 100% 0%
|
||||
)`
|
||||
: 'none';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] pointer-events-none">
|
||||
{/* Backdrop with hole */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 pointer-events-auto"
|
||||
onClick={onClose}
|
||||
style={{ clipPath: backdropClipPath }}
|
||||
/>
|
||||
)` : 'none'
|
||||
}} />
|
||||
|
||||
{/* Highlight border */}
|
||||
<div
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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(),
|
||||
}));
|
||||
|
||||
vi.mock('../../contexts/AuthContext', () => ({
|
||||
useAuth: () => useAuthMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../../contexts/LanguageContext', () => ({
|
||||
useLanguage: () => ({
|
||||
t: {
|
||||
common: { loading: '加载中', processing: '处理中' },
|
||||
alerts: {
|
||||
taskTimeout: '超时',
|
||||
networkError: '网络错误',
|
||||
uploadFailed: '上传失败',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/uploadService', () => ({
|
||||
uploadService: {
|
||||
getTaskList: vi.fn().mockResolvedValue({ task_list: [], total: 0 }),
|
||||
getTaskResult: vi.fn(),
|
||||
calculateMD5: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
createRecognitionTask: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../components/Navbar', () => ({
|
||||
default: () => <div>navbar</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/LeftSidebar', () => ({
|
||||
default: ({
|
||||
onUploadClick,
|
||||
onRequireAuth,
|
||||
canUploadAnonymously,
|
||||
}: {
|
||||
onUploadClick: () => void;
|
||||
onRequireAuth: () => void;
|
||||
canUploadAnonymously: boolean;
|
||||
}) => (
|
||||
<div>
|
||||
<button onClick={onUploadClick}>open-upload</button>
|
||||
<button onClick={onRequireAuth}>open-auth</button>
|
||||
<span>{canUploadAnonymously ? 'guest-allowed' : 'guest-blocked'}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/FilePreview', () => ({
|
||||
default: ({ file }: { file: { id: string } | null }) => <div>{file ? `preview:${file.id}` : 'preview-empty'}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ResultPanel', () => ({
|
||||
default: () => <div>result</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/UploadModal', () => ({
|
||||
default: () => <div>upload-modal</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/UserGuide', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/AuthModal', () => ({
|
||||
default: () => <div>auth-modal</div>,
|
||||
}));
|
||||
|
||||
describe('App anonymous usage limit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
localStorage.setItem('hasSeenGuide', 'true');
|
||||
useAuthMock.mockReturnValue({
|
||||
user: null,
|
||||
initializing: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows anonymous upload before the limit', () => {
|
||||
localStorage.setItem('texpixel_guest_usage_count', '2');
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText('guest-allowed')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('open-upload'));
|
||||
|
||||
expect(screen.getByText('upload-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forces login after three anonymous uses', () => {
|
||||
localStorage.setItem('texpixel_guest_usage_count', '3');
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText('guest-blocked')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('open-upload'));
|
||||
|
||||
expect(screen.getByText('auth-modal')).toBeInTheDocument();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
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: '注册',
|
||||
signInTitle: '登录账号',
|
||||
signUpTitle: '注册账号',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
error: '错误',
|
||||
genericError: '发生错误,请重试',
|
||||
hasAccount: '已有账号?去登录',
|
||||
noAccount: '没有账号?去注册',
|
||||
continueWithGoogle: 'Google',
|
||||
emailHint: '仅用于登录和同步记录。',
|
||||
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
|
||||
confirmPassword: '确认密码',
|
||||
passwordMismatch: '两次输入的密码不一致。',
|
||||
emailRequired: '请输入邮箱地址。',
|
||||
emailInvalid: '请输入有效的邮箱地址。',
|
||||
passwordRequired: '请输入密码。',
|
||||
sendCode: '发送验证码',
|
||||
resendCode: '重新发送',
|
||||
codeSent: '验证码已发送',
|
||||
verificationCode: '验证码',
|
||||
verificationCodePlaceholder: '请输入 6 位验证码',
|
||||
verificationCodeRequired: '请输入验证码。',
|
||||
verificationCodeHint: '请查收邮箱中的 6 位验证码。',
|
||||
sendCodeFailed: '发送验证码失败,请重试。',
|
||||
oauthRedirecting: '正在跳转 Google...',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
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(createAuthState({ authPhase: 'oauth_redirecting' }));
|
||||
|
||||
render(<AuthModal onClose={vi.fn()} />);
|
||||
|
||||
const emailInput = screen.getByLabelText('邮箱');
|
||||
const submitButton = document.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
|
||||
expect(emailInput).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('switches between signin and signup with segmented tabs', () => {
|
||||
useAuthMock.mockReturnValue(createAuthState());
|
||||
|
||||
render(<AuthModal onClose={vi.fn()} />);
|
||||
|
||||
const signupTab = screen.getByRole('button', { name: '注册', pressed: false });
|
||||
fireEvent.click(signupTab);
|
||||
|
||||
expect(screen.getByRole('button', { name: '注册', pressed: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows friendlier signup guidance', () => {
|
||||
useAuthMock.mockReturnValue(createAuthState());
|
||||
|
||||
render(<AuthModal onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '注册', pressed: false }));
|
||||
|
||||
expect(screen.getByText(/密码至少 6 位/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/仅用于登录和同步记录/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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 < 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>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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"> </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>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +1,122 @@
|
||||
import { createContext, useContext, useMemo, ReactNode, useCallback, useEffect, useReducer, useRef } from 'react';
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { authService } from '../lib/authService';
|
||||
import { ApiErrorMessages } from '../types/api';
|
||||
import type { GoogleOAuthCallbackRequest, UserInfo } from '../types/api';
|
||||
import { authReducer, createInitialAuthState, type AuthPhase } from './authMachine';
|
||||
|
||||
export const OAUTH_STATE_KEY = 'texpixel_oauth_state';
|
||||
export const OAUTH_POST_LOGIN_REDIRECT_KEY = 'texpixel_post_login_redirect';
|
||||
import type { UserInfo } from '../types/api';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserInfo | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
initializing: boolean;
|
||||
authPhase: AuthPhase;
|
||||
authError: string | null;
|
||||
initializing: boolean; // 新增初始化状态
|
||||
signIn: (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 }>;
|
||||
signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
|
||||
signOut: () => Promise<void>;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
const oauthExchangeInFlight = new Map<string, Promise<{ error: Error | null }>>();
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const apiError = error as { code: number; message?: string };
|
||||
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function createOAuthState(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function mergeUserProfile(user: UserInfo, profile: { username: string; email: string }): UserInfo {
|
||||
return {
|
||||
...user,
|
||||
username: profile.username || user.username || '',
|
||||
email: profile.email || user.email,
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const restoredSession = authService.restoreSession();
|
||||
// 直接在 useState 初始化函数中同步恢复会话
|
||||
const [user, setUser] = useState<UserInfo | null>(() => {
|
||||
try {
|
||||
const session = authService.restoreSession();
|
||||
return session ? session.user : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
authReducer,
|
||||
createInitialAuthState(restoredSession ? { user: restoredSession.user, token: restoredSession.token } : null)
|
||||
);
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
try {
|
||||
const session = authService.restoreSession();
|
||||
return session ? session.token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initializing, setInitializing] = useState(false); // 不需要初始化过程了,因为是同步的
|
||||
|
||||
// 不再需要 useEffect 里的 restoreSession
|
||||
|
||||
|
||||
/**
|
||||
* 从错误对象中提取用户友好的错误消息
|
||||
*/
|
||||
const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||
// 检查是否是 ApiError(通过 code 属性判断,避免 instanceof 在热更新时失效)
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const apiError = error as { code: number; message: string };
|
||||
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
const signIn = useCallback(async (email: string, password: string) => {
|
||||
dispatch({ type: 'EMAIL_SIGNIN_START' });
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await authService.login({ email, password });
|
||||
dispatch({ type: 'EMAIL_SIGNIN_SUCCESS', payload: { user: result.user, token: result.token } });
|
||||
setUser(result.user);
|
||||
setToken(result.token);
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, '登录失败');
|
||||
dispatch({ type: 'EMAIL_SIGNIN_FAIL', payload: { error: message } });
|
||||
return { error: new Error(message) };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signUp = useCallback(async (email: string, password: string, code: string) => {
|
||||
dispatch({ type: 'EMAIL_SIGNUP_START' });
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
const signUp = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await authService.register({ email, password, code });
|
||||
dispatch({ type: 'EMAIL_SIGNUP_SUCCESS', payload: { user: result.user, token: result.token } });
|
||||
const result = await authService.register({ email, password });
|
||||
setUser(result.user);
|
||||
setToken(result.token);
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, '注册失败');
|
||||
dispatch({ type: 'EMAIL_SIGNUP_FAIL', payload: { error: message } });
|
||||
return { error: new Error(message) };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const beginGoogleOAuth = useCallback(async () => {
|
||||
dispatch({ type: 'OAUTH_REDIRECT_START' });
|
||||
|
||||
try {
|
||||
const stateToken = createOAuthState();
|
||||
const redirectUri = `${window.location.origin}/auth/google/callback`;
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, stateToken);
|
||||
sessionStorage.setItem(OAUTH_POST_LOGIN_REDIRECT_KEY, window.location.href);
|
||||
|
||||
const { authUrl } = await authService.getGoogleOAuthUrl(redirectUri, stateToken);
|
||||
window.location.assign(authUrl);
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Google 登录失败');
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: message } });
|
||||
return { error: new Error(message) };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completeGoogleOAuth = useCallback(async (params: GoogleOAuthCallbackRequest) => {
|
||||
const requestKey = params.code;
|
||||
const existing = oauthExchangeInFlight.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_START' });
|
||||
|
||||
try {
|
||||
const expectedState = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
if (!expectedState || expectedState !== params.state) {
|
||||
const invalidStateMessage = 'OAuth state 校验失败';
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: invalidStateMessage } });
|
||||
return { error: new Error(invalidStateMessage) };
|
||||
}
|
||||
|
||||
const result = await authService.exchangeGoogleCode(params);
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_SUCCESS', payload: { user: result.user, token: result.token } });
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Google 登录失败');
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: message } });
|
||||
return { error: new Error(message) };
|
||||
} finally {
|
||||
oauthExchangeInFlight.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
|
||||
oauthExchangeInFlight.set(requestKey, promise);
|
||||
return promise;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
const signOut = useCallback(async () => {
|
||||
authService.logout();
|
||||
dispatch({ type: 'SIGN_OUT' });
|
||||
setLoading(true);
|
||||
try {
|
||||
authService.logout();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncedTokenRef = useRef<string | null>(null);
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_USER',
|
||||
payload: {
|
||||
user: mergeUserProfile(currentUser, profile),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Keep token-derived identity if profile sync fails.
|
||||
if (!cancelled) {
|
||||
// 请求失败时重置,允许下次挂载时重试
|
||||
syncedTokenRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void syncUserProfile();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [state.token]);
|
||||
|
||||
const value = useMemo<AuthContextType>(() => {
|
||||
const loadingPhases: AuthPhase[] = [
|
||||
'email_signing_in',
|
||||
'email_signing_up',
|
||||
'oauth_redirecting',
|
||||
'oauth_exchanging',
|
||||
];
|
||||
|
||||
return {
|
||||
user: state.user,
|
||||
token: state.token,
|
||||
loading: loadingPhases.includes(state.authPhase),
|
||||
initializing: state.initializing,
|
||||
authPhase: state.authPhase,
|
||||
authError: state.authError,
|
||||
signIn,
|
||||
signUp,
|
||||
beginGoogleOAuth,
|
||||
completeGoogleOAuth,
|
||||
signOut,
|
||||
isAuthenticated: !!state.user && !!state.token,
|
||||
};
|
||||
}, [beginGoogleOAuth, completeGoogleOAuth, signIn, signOut, signUp, state]);
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
initializing,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
isAuthenticated: !!user && !!token,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ interface LanguageContextType {
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// 初始化语言:优先使用 localStorage,否则默认英文,后续由 IP 检测决定
|
||||
// 初始化语言:优先使用 localStorage,否则使用浏览器语言作为临时值
|
||||
const [language, setLanguageState] = useState<Language>(() => {
|
||||
const saved = localStorage.getItem('language');
|
||||
if (saved === 'en' || saved === 'zh') return saved;
|
||||
return 'en';
|
||||
// 临时使用浏览器语言,后续会被IP检测覆盖(如果没有保存的语言)
|
||||
return navigator.language.startsWith('zh') ? 'zh' : 'en';
|
||||
});
|
||||
|
||||
// 检测IP地理位置并设置语言(仅在首次加载且没有保存的语言时)
|
||||
@@ -29,14 +30,16 @@ 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
|
||||
});
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { AuthProvider, useAuth } from '../AuthContext';
|
||||
|
||||
const {
|
||||
loginMock,
|
||||
registerMock,
|
||||
logoutMock,
|
||||
restoreSessionMock,
|
||||
getGoogleOAuthUrlMock,
|
||||
exchangeGoogleCodeMock,
|
||||
} = vi.hoisted(() => ({
|
||||
loginMock: vi.fn(),
|
||||
registerMock: vi.fn(),
|
||||
logoutMock: vi.fn(),
|
||||
restoreSessionMock: vi.fn(() => null),
|
||||
getGoogleOAuthUrlMock: vi.fn(),
|
||||
exchangeGoogleCodeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/authService', () => ({
|
||||
authService: {
|
||||
login: loginMock,
|
||||
register: registerMock,
|
||||
logout: logoutMock,
|
||||
restoreSession: restoreSessionMock,
|
||||
getGoogleOAuthUrl: getGoogleOAuthUrlMock,
|
||||
exchangeGoogleCode: exchangeGoogleCodeMock,
|
||||
},
|
||||
}));
|
||||
|
||||
function Harness({ onReady }: { onReady: (ctx: ReturnType<typeof useAuth>) => void }) {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
onReady(auth);
|
||||
}, [auth, onReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderWithProvider(onReady: (ctx: ReturnType<typeof useAuth>) => void) {
|
||||
return render(
|
||||
<AuthProvider>
|
||||
<Harness onReady={onReady} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AuthContext OAuth flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
restoreSessionMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('beginGoogleOAuth writes state and redirect then redirects browser', async () => {
|
||||
getGoogleOAuthUrlMock.mockResolvedValue({ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth' });
|
||||
let ctxRef: ReturnType<typeof useAuth> | null = null;
|
||||
|
||||
renderWithProvider((ctx) => {
|
||||
ctxRef = ctx;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctxRef).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await (ctxRef as ReturnType<typeof useAuth>).beginGoogleOAuth();
|
||||
});
|
||||
|
||||
expect(sessionStorage.getItem('texpixel_oauth_state')).toBeTruthy();
|
||||
expect(sessionStorage.getItem('texpixel_post_login_redirect')).toBe(window.location.href);
|
||||
expect(getGoogleOAuthUrlMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('completeGoogleOAuth rejects when state mismatches', async () => {
|
||||
sessionStorage.setItem('texpixel_oauth_state', 'expected_state');
|
||||
let ctxRef: ReturnType<typeof useAuth> | null = null;
|
||||
|
||||
renderWithProvider((ctx) => {
|
||||
ctxRef = ctx;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctxRef).toBeTruthy();
|
||||
});
|
||||
|
||||
let result: { error: Error | null } = { error: null };
|
||||
await act(async () => {
|
||||
result = await (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
|
||||
code: 'abc',
|
||||
state: 'wrong_state',
|
||||
redirect_uri: 'http://localhost:5173/auth/google/callback',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(exchangeGoogleCodeMock).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('texpixel_token')).toBeNull();
|
||||
});
|
||||
|
||||
it('completeGoogleOAuth stores session on success', async () => {
|
||||
sessionStorage.setItem('texpixel_oauth_state', 'state_ok');
|
||||
exchangeGoogleCodeMock.mockImplementation(async () => {
|
||||
localStorage.setItem('texpixel_token', 'Bearer header.payload.sig');
|
||||
return {
|
||||
token: 'Bearer header.payload.sig',
|
||||
expiresAt: 1999999999,
|
||||
user: {
|
||||
user_id: 7,
|
||||
id: '7',
|
||||
email: 'oauth@example.com',
|
||||
exp: 1999999999,
|
||||
iat: 1111111,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let ctxRef: ReturnType<typeof useAuth> | null = null;
|
||||
|
||||
renderWithProvider((ctx) => {
|
||||
ctxRef = ctx;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctxRef).toBeTruthy();
|
||||
});
|
||||
|
||||
let result: { error: Error | null } = { error: null };
|
||||
await act(async () => {
|
||||
result = await (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
|
||||
code: 'code_ok',
|
||||
state: 'state_ok',
|
||||
redirect_uri: 'http://localhost:5173/auth/google/callback',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect((ctxRef as ReturnType<typeof useAuth>).isAuthenticated).toBe(true);
|
||||
});
|
||||
expect(localStorage.getItem('texpixel_token')).toBe('Bearer header.payload.sig');
|
||||
});
|
||||
|
||||
it('completeGoogleOAuth deduplicates same code requests', async () => {
|
||||
sessionStorage.setItem('texpixel_oauth_state', 'state_ok');
|
||||
|
||||
exchangeGoogleCodeMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
localStorage.setItem('texpixel_token', 'Bearer header.payload.sig');
|
||||
resolve({
|
||||
token: 'Bearer header.payload.sig',
|
||||
expiresAt: 1999999999,
|
||||
user: {
|
||||
user_id: 7,
|
||||
id: '7',
|
||||
email: 'oauth@example.com',
|
||||
exp: 1999999999,
|
||||
iat: 1111111,
|
||||
},
|
||||
});
|
||||
}, 20);
|
||||
})
|
||||
);
|
||||
|
||||
let ctxRef: ReturnType<typeof useAuth> | null = null;
|
||||
|
||||
renderWithProvider((ctx) => {
|
||||
ctxRef = ctx;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ctxRef).toBeTruthy();
|
||||
});
|
||||
|
||||
let result1: { error: Error | null } = { error: null };
|
||||
let result2: { error: Error | null } = { error: null };
|
||||
|
||||
await act(async () => {
|
||||
const p1 = (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
|
||||
code: 'same_code',
|
||||
state: 'state_ok',
|
||||
redirect_uri: 'http://localhost:5173/auth/google/callback',
|
||||
});
|
||||
|
||||
const p2 = (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
|
||||
code: 'same_code',
|
||||
state: 'state_ok',
|
||||
redirect_uri: 'http://localhost:5173/auth/google/callback',
|
||||
});
|
||||
|
||||
[result1, result2] = await Promise.all([p1, p2]);
|
||||
});
|
||||
|
||||
expect(result1.error).toBeNull();
|
||||
expect(result2.error).toBeNull();
|
||||
expect(exchangeGoogleCodeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { UserInfo } from '../types/api';
|
||||
|
||||
export type AuthPhase =
|
||||
| 'idle'
|
||||
| 'email_signing_in'
|
||||
| 'email_signing_up'
|
||||
| 'oauth_redirecting'
|
||||
| 'oauth_exchanging'
|
||||
| 'authenticated'
|
||||
| 'error';
|
||||
|
||||
export interface AuthState {
|
||||
user: UserInfo | null;
|
||||
token: string | null;
|
||||
authPhase: AuthPhase;
|
||||
authError: string | null;
|
||||
initializing: boolean;
|
||||
}
|
||||
|
||||
export type AuthAction =
|
||||
| { type: 'RESTORE_SESSION'; payload: { user: UserInfo | null; token: string | null } }
|
||||
| { type: 'EMAIL_SIGNIN_START' }
|
||||
| { type: 'EMAIL_SIGNIN_SUCCESS'; payload: { user: UserInfo; token: string } }
|
||||
| { type: 'EMAIL_SIGNIN_FAIL'; payload: { error: string } }
|
||||
| { type: 'EMAIL_SIGNUP_START' }
|
||||
| { type: 'EMAIL_SIGNUP_SUCCESS'; payload: { user: UserInfo; token: string } }
|
||||
| { type: 'EMAIL_SIGNUP_FAIL'; payload: { error: string } }
|
||||
| { type: 'OAUTH_REDIRECT_START' }
|
||||
| { type: 'OAUTH_EXCHANGE_START' }
|
||||
| { type: 'OAUTH_EXCHANGE_SUCCESS'; payload: { user: UserInfo; token: string } }
|
||||
| { type: 'OAUTH_EXCHANGE_FAIL'; payload: { error: string } }
|
||||
| { type: 'UPDATE_USER'; payload: { user: UserInfo } }
|
||||
| { type: 'SIGN_OUT' };
|
||||
|
||||
export function createInitialAuthState(session: { user: UserInfo; token: string } | null): AuthState {
|
||||
if (session) {
|
||||
return {
|
||||
user: session.user,
|
||||
token: session.token,
|
||||
authPhase: 'authenticated',
|
||||
authError: null,
|
||||
initializing: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
user: null,
|
||||
token: null,
|
||||
authPhase: 'idle',
|
||||
authError: null,
|
||||
initializing: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||
switch (action.type) {
|
||||
case 'RESTORE_SESSION': {
|
||||
if (action.payload.user && action.payload.token) {
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
authPhase: 'authenticated',
|
||||
authError: null,
|
||||
initializing: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
token: null,
|
||||
authPhase: 'idle',
|
||||
authError: null,
|
||||
initializing: false,
|
||||
};
|
||||
}
|
||||
|
||||
case 'EMAIL_SIGNIN_START':
|
||||
return { ...state, authPhase: 'email_signing_in', authError: null };
|
||||
case 'EMAIL_SIGNIN_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
authPhase: 'authenticated',
|
||||
authError: null,
|
||||
};
|
||||
case 'EMAIL_SIGNIN_FAIL':
|
||||
return { ...state, authPhase: 'error', authError: action.payload.error };
|
||||
|
||||
case 'EMAIL_SIGNUP_START':
|
||||
return { ...state, authPhase: 'email_signing_up', authError: null };
|
||||
case 'EMAIL_SIGNUP_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
authPhase: 'authenticated',
|
||||
authError: null,
|
||||
};
|
||||
case 'EMAIL_SIGNUP_FAIL':
|
||||
return { ...state, authPhase: 'error', authError: action.payload.error };
|
||||
|
||||
case 'OAUTH_REDIRECT_START':
|
||||
return { ...state, authPhase: 'oauth_redirecting', authError: null };
|
||||
case 'OAUTH_EXCHANGE_START':
|
||||
return { ...state, authPhase: 'oauth_exchanging', authError: null };
|
||||
case 'OAUTH_EXCHANGE_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
authPhase: 'authenticated',
|
||||
authError: null,
|
||||
};
|
||||
case 'OAUTH_EXCHANGE_FAIL':
|
||||
return { ...state, authPhase: 'error', authError: action.payload.error };
|
||||
|
||||
case 'UPDATE_USER':
|
||||
return { ...state, user: action.payload.user };
|
||||
|
||||
case 'SIGN_OUT':
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
token: null,
|
||||
authPhase: 'idle',
|
||||
authError: null,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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();
|
||||
}, []);
|
||||
}
|
||||
137
src/index.css
@@ -2,112 +2,6 @@
|
||||
@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;
|
||||
@@ -119,34 +13,15 @@
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(168, 162, 158, 0.3);
|
||||
background-color: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(168, 162, 158, 0.5);
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
|
||||
/* Fade-in animation */
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.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; }
|
||||
}
|
||||
|
||||
body {
|
||||
@apply antialiased text-gray-900 bg-gray-50;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,6 @@ export class ApiError extends Error {
|
||||
*/
|
||||
interface RequestConfig extends RequestInit {
|
||||
skipAuth?: boolean;
|
||||
successCodes?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +82,7 @@ async function request<T>(
|
||||
endpoint: string,
|
||||
config: RequestConfig = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const { skipAuth = false, successCodes = [200], headers: customHeaders, ...restConfig } = config;
|
||||
const { skipAuth = false, headers: customHeaders, ...restConfig } = config;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -109,7 +108,7 @@ async function request<T>(
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
|
||||
// 统一处理业务错误
|
||||
if (!successCodes.includes(data.code)) {
|
||||
if (data.code !== 200) {
|
||||
throw new ApiError(data.code, data.message, data.request_id);
|
||||
}
|
||||
|
||||
@@ -154,3 +153,4 @@ export const http = {
|
||||
};
|
||||
|
||||
export default http;
|
||||
|
||||
|
||||
@@ -1,62 +1,36 @@
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理用户登录、注册、OAuth、登出等认证相关操作
|
||||
* 处理用户登录、注册、登出等认证相关操作
|
||||
*/
|
||||
|
||||
import { http, tokenManager, ApiError } from './api';
|
||||
import type {
|
||||
AuthData,
|
||||
GoogleAuthUrlData,
|
||||
GoogleOAuthCallbackRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
SendEmailCodeRequest,
|
||||
UserInfoData,
|
||||
UserInfo,
|
||||
} from '../types/api';
|
||||
import type { AuthData, LoginRequest, RegisterRequest, UserInfo } from '../types/api';
|
||||
|
||||
// 重新导出 ApiErrorMessages 以便使用
|
||||
export { ApiErrorMessages } from '../types/api';
|
||||
|
||||
function decodeJwtPayload(token: string): UserInfo | null {
|
||||
/**
|
||||
* 从 JWT Token 解析用户信息
|
||||
*/
|
||||
function parseJwtPayload(token: string): UserInfo | null {
|
||||
try {
|
||||
// 移除 Bearer 前缀
|
||||
const actualToken = token.replace('Bearer ', '');
|
||||
const base64Payload = actualToken.split('.')[1];
|
||||
const normalized = base64Payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
const payload = JSON.parse(atob(padded));
|
||||
const payload = JSON.parse(atob(base64Payload));
|
||||
return payload as UserInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUser(payload: UserInfo, emailHint?: string): UserInfo {
|
||||
return {
|
||||
...payload,
|
||||
email: payload.email || emailHint || '',
|
||||
id: String(payload.user_id),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(authData: AuthData, emailHint?: string): { user: UserInfo; token: string; expiresAt: number } {
|
||||
const { token, expires_at } = authData;
|
||||
const parsedUser = decodeJwtPayload(token);
|
||||
|
||||
if (!parsedUser) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
const user = normalizeUser(parsedUser, emailHint);
|
||||
tokenManager.setToken(token, expires_at, user.email || undefined);
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
*/
|
||||
export const authService = {
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true });
|
||||
|
||||
@@ -64,13 +38,35 @@ export const authService = {
|
||||
throw new ApiError(-1, '登录失败,请重试');
|
||||
}
|
||||
|
||||
return buildSession(response.data, credentials.email);
|
||||
},
|
||||
|
||||
async sendEmailCode(email: string): Promise<void> {
|
||||
await http.post<null>('/user/email/code', { email } satisfies SendEmailCodeRequest, { skipAuth: true });
|
||||
const { token, expires_at } = response.data;
|
||||
|
||||
// 存储 Token 和 email
|
||||
tokenManager.setToken(token, expires_at, credentials.email);
|
||||
|
||||
// 解析用户信息
|
||||
const user = parseJwtPayload(token);
|
||||
if (!user) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const userWithEmail: UserInfo = {
|
||||
...user,
|
||||
email: credentials.email,
|
||||
id: String(user.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user: userWithEmail,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* 注意:业务错误码 (code !== 200) 已在 api.ts 中统一处理,会抛出 ApiError
|
||||
*/
|
||||
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
|
||||
|
||||
@@ -78,58 +74,55 @@ export const authService = {
|
||||
throw new ApiError(-1, '注册失败,请重试');
|
||||
}
|
||||
|
||||
return buildSession(response.data, credentials.email);
|
||||
},
|
||||
const { token, expires_at } = response.data;
|
||||
|
||||
async getGoogleOAuthUrl(redirectUri: string, state: string): Promise<{ authUrl: string }> {
|
||||
const query = new URLSearchParams({ redirect_uri: redirectUri, state });
|
||||
const response = await http.get<GoogleAuthUrlData>(`/user/oauth/google/url?${query.toString()}`, {
|
||||
skipAuth: true,
|
||||
});
|
||||
// 存储 Token 和 email
|
||||
tokenManager.setToken(token, expires_at, credentials.email);
|
||||
|
||||
if (!response.data?.auth_url) {
|
||||
throw new ApiError(-1, '获取 Google 授权地址失败');
|
||||
// 解析用户信息
|
||||
const user = parseJwtPayload(token);
|
||||
if (!user) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
return { authUrl: response.data.auth_url };
|
||||
},
|
||||
|
||||
async exchangeGoogleCode(
|
||||
payload: GoogleOAuthCallbackRequest
|
||||
): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/oauth/google/callback', payload, { skipAuth: true });
|
||||
|
||||
if (!response.data) {
|
||||
throw new ApiError(-1, 'Google 登录失败,请重试');
|
||||
}
|
||||
|
||||
return buildSession(response.data);
|
||||
},
|
||||
|
||||
async getUserInfo(): Promise<UserInfoData> {
|
||||
const response = await http.get<UserInfoData>('/user/info', {
|
||||
successCodes: [0, 200],
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
throw new ApiError(-1, '获取用户信息失败');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
// 补充 email 和 id 兼容字段
|
||||
const userWithEmail: UserInfo = {
|
||||
...user,
|
||||
email: credentials.email,
|
||||
id: String(user.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user: userWithEmail,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
logout(): void {
|
||||
tokenManager.removeToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return tokenManager.isTokenValid();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前存储的 Token
|
||||
*/
|
||||
getToken(): string | null {
|
||||
return tokenManager.getToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* 从存储的 Token 恢复用户会话
|
||||
*/
|
||||
restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null {
|
||||
const token = tokenManager.getToken();
|
||||
const expiresAt = tokenManager.getExpiresAt();
|
||||
@@ -140,14 +133,21 @@ export const authService = {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUser = decodeJwtPayload(token);
|
||||
const parsedUser = parseJwtPayload(token);
|
||||
if (!parsedUser) {
|
||||
tokenManager.removeToken();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const user: UserInfo = {
|
||||
...parsedUser,
|
||||
email: email || '',
|
||||
id: String(parsedUser.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user: normalizeUser(parsedUser, email || ''),
|
||||
user,
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
@@ -156,3 +156,4 @@ export const authService = {
|
||||
|
||||
export { ApiError };
|
||||
export default authService;
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -47,11 +47,24 @@ export async function detectCountryByIP(): Promise<string | null> {
|
||||
/**
|
||||
* 根据国家代码判断应该使用的语言
|
||||
*
|
||||
* @param countryCode 国家代码(如 'CN', 'US')
|
||||
* @param countryCode 国家代码(如 'CN', 'TW', 'HK', 'SG' 等)
|
||||
* @returns 'zh' | 'en' 推荐的语言
|
||||
*/
|
||||
export function getLanguageByCountry(countryCode: string | null): 'zh' | 'en' {
|
||||
return countryCode === 'CN' ? 'zh' : 'en';
|
||||
if (!countryCode) {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// 中文地区列表
|
||||
const chineseRegions = [
|
||||
'CN', // 中国大陆
|
||||
'TW', // 台湾
|
||||
'HK', // 香港
|
||||
'MO', // 澳门
|
||||
'SG', // 新加坡(主要使用中文)
|
||||
];
|
||||
|
||||
return chineseRegions.includes(countryCode) ? 'zh' : 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,14 +8,14 @@ interface SEOContent {
|
||||
|
||||
const seoContent: Record<Language, SEOContent> = {
|
||||
zh: {
|
||||
title: 'TexPixel - AI 数学公式识别工具 | LaTeX、MathML OCR',
|
||||
description: '免费 AI 数学公式识别工具,支持手写和印刷体公式识别,一键将图片或 PDF 中的数学公式转换为 LaTeX、MathML 和 Markdown 格式。',
|
||||
keywords: '数学公式识别,LaTeX OCR,手写公式识别,公式转LaTeX,数学OCR,MathML转换,手写方程识别,公式识别,数学公式OCR,texpixel',
|
||||
title: '⚡️ TexPixel - 公式识别工具',
|
||||
description: '在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。',
|
||||
keywords: '公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,混合文字识别,texpixel,TexPixel',
|
||||
},
|
||||
en: {
|
||||
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',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -35,8 +35,6 @@ 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,26 +60,6 @@ export const translations = {
|
||||
genericError: 'An error occurred, please try again',
|
||||
hasAccount: 'Already have an account? Login',
|
||||
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.',
|
||||
oauthRedirecting: 'Redirecting to Google...',
|
||||
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',
|
||||
@@ -115,69 +93,7 @@ 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: {
|
||||
@@ -215,8 +131,6 @@ export const translations = {
|
||||
noHistory: '暂无历史记录',
|
||||
noMore: '没有更多记录了',
|
||||
historyHeader: '历史记录',
|
||||
historyToggle: '显示历史',
|
||||
historyLoginRequired: '登录后开启历史记录',
|
||||
},
|
||||
uploadModal: {
|
||||
title: '上传文件',
|
||||
@@ -242,26 +156,6 @@ export const translations = {
|
||||
genericError: '发生错误,请重试',
|
||||
hasAccount: '已有账号?去登录',
|
||||
noAccount: '没有账号?去注册',
|
||||
continueWithGoogle: 'Google',
|
||||
emailHint: '仅用于登录和同步记录。',
|
||||
emailRequired: '请输入邮箱地址。',
|
||||
emailInvalid: '请输入有效的邮箱地址。',
|
||||
passwordRequired: '请输入密码。',
|
||||
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
|
||||
confirmPassword: '确认密码',
|
||||
passwordMismatch: '两次输入的密码不一致。',
|
||||
oauthRedirecting: '正在跳转 Google...',
|
||||
oauthExchanging: '正在完成 Google 登录...',
|
||||
invalidOAuthState: 'OAuth 状态校验失败,请重试',
|
||||
oauthFailed: 'Google 登录失败,请重试',
|
||||
sendCode: '发送验证码',
|
||||
resendCode: '重新发送',
|
||||
codeSent: '验证码已发送',
|
||||
verificationCode: '验证码',
|
||||
verificationCodePlaceholder: '请输入 6 位验证码',
|
||||
verificationCodeRequired: '请输入验证码。',
|
||||
verificationCodeHint: '请查收邮箱中的 6 位验证码。',
|
||||
sendCodeFailed: '发送验证码失败,请重试。',
|
||||
},
|
||||
export: {
|
||||
title: '导出',
|
||||
@@ -295,69 +189,7 @@ 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: '联系方式',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
18
src/main.tsx
@@ -1,11 +1,9 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
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';
|
||||
|
||||
// 错误处理:捕获未处理的错误
|
||||
window.addEventListener('error', (event) => {
|
||||
@@ -24,15 +22,11 @@ if (!rootElement) {
|
||||
try {
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<HelmetProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
<AppRouter />
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</HelmetProvider>
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
<App />
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
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 能在一秒内将它们转换为
|
||||
可直接复制使用的 LaTeX、Markdown 或 Word 公式。
|
||||
</p>
|
||||
|
||||
<h2>我们做什么</h2>
|
||||
<p>
|
||||
我们的核心产品是一个专为数学符号训练的 AI 文档识别引擎。与通用 OCR 工具不同,
|
||||
TexPixel 能理解公式的结构——分数、积分、求和、矩阵、多行表达式——并生成一次就能用的输出。
|
||||
</p>
|
||||
<p>我们提供:</p>
|
||||
<ul>
|
||||
<li><strong>Web 应用</strong>——在浏览器中即时识别,无需安装。</li>
|
||||
<li><strong>API(Beta)</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth, OAUTH_POST_LOGIN_REDIRECT_KEY } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
function toInternalPath(urlOrPath: string): string {
|
||||
try {
|
||||
const parsed = new URL(urlOrPath, window.location.origin);
|
||||
if (parsed.origin !== window.location.origin) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { completeGoogleOAuth } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const code = useMemo(() => searchParams.get('code') ?? '', [searchParams]);
|
||||
const state = useMemo(() => searchParams.get('state') ?? '', [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const run = async () => {
|
||||
if (!code || !state) {
|
||||
if (mounted) {
|
||||
setError(t.auth.oauthFailed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/google/callback`;
|
||||
const result = await completeGoogleOAuth({ code, state, redirect_uri: redirectUri });
|
||||
|
||||
if (result.error) {
|
||||
if (mounted) {
|
||||
setError(result.error.message || t.auth.oauthFailed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectTarget = sessionStorage.getItem(OAUTH_POST_LOGIN_REDIRECT_KEY) || '/';
|
||||
navigate(toInternalPath(redirectTarget), { replace: true });
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [code, completeGoogleOAuth, navigate, state, t.auth.oauthFailed]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 text-center">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-3">Google OAuth</h1>
|
||||
{!error && <p className="text-gray-600">{t.auth.oauthExchanging}</p>}
|
||||
{error && (
|
||||
<>
|
||||
<p className="text-red-600 text-sm mb-4">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/', { replace: true })}
|
||||
className="px-4 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Back Home
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||