跳到正文
DEV Community· Abid Ali·· 3 小時前AI 評分44

兩款小型應用、兩個難題:Amir Ali 的個人項目對前端工程有何啟示

Two Small Apps, Two Hard Problems: What Amir Ali's Side Projects Teach About Frontend Engineering

AI 導讀

Amir Ali 的兩個開源項目 MatchReady 和 Smart Doc,分別以純時間函數令動畫預覽與 GIF 輸出逐格一致,並以樂觀鎖定拒絕過期文件寫入。MatchReady 在瀏覽器運行,播放與 GIF 匯出共用 positionsAt,GIF 編碼由 Web Worker 處理。Smart Doc 設有 Viewer、Commenter、Editor 角色,並支援文字留言及版本還原。

正文

You can learn more about a developer from one well-documented side project than from a long list of skills on a résumé. When I want to recommend someone, I read their code first.

So this post is a code review of two recent projects by my friend Amir Ali, a full-stack developer in Karachi, Pakistan who works mainly in React, Next.js and TypeScript. Each project takes on a problem that looks simple and isn't:

  1. MatchReady: how to get smooth animation on a canvas where the preview and the exported GIF match frame for frame.
  2. Smart Doc: how to let several people edit one document without anyone quietly overwriting someone else's work.

Both are open source and both have live demos, so you can check everything below yourself.


Part 1: MatchReady, where animation is a function of time

MatchReady is a 2D soccer drill builder. You place players, cones and goals on a pitch, draw passes, runs and dribbles as paths, play the drill back as an animation, and export it as a GIF. It runs entirely in the browser. There's no backend and no account, and drills are saved to IndexedDB.

The stack, from the README: Next.js 15 (TypeScript strict), react-konva for the canvas, Zustand + zundo for state with undo/redo, Dexie for persistence, and gifenc running in a Web Worker.

The core decision: no accumulated state

Animation code usually goes wrong by mutating positions a little on every frame. That approach drifts, breaks when you scrub, and makes exports differ from previews. Amir avoided it. The header comment in lib/playback.ts says it plainly:

// Playback is a PURE FUNCTION OF TIME. compileDrill() precomputes sampled
// splines and per-action timing; positionsAt() maps a time to entity positions.
// Both the live rAF player and the GIF exporter call positionsAt, so what you
// preview is exactly what you export. No side effects, no accumulated state.

Source: amirsaeed671/MatchReady, lib/playback.ts

There are two phases. compileDrill() runs once per edit and works out when each step starts and ends. Steps play one after another with a small gap, and the actions inside a step play in parallel. positionsAt(compiled, t) is then a pure lookup. Scrubbing, playback and GIF export all call the same function, so "the GIF looks different from the preview" can't happen.

Uniform speed along curves

The paths players run are curved, and the geometry is where the project gets interesting. If you move along a spline by stepping its raw parameter t at a constant rate, things visibly speed up and slow down on curves. lib/geometry.ts handles this in two steps: it samples a centripetal Catmull-Rom spline (alpha = 0.5), then reparameterises it by arc length so that equal time steps cover equal distances:

/** Point at absolute arc length `s` (clamped to [0, length]). */
export function pointAtArcLength(path: SampledPath, s: number): Point {
  // ...
  // Binary search for the last index whose cumulative <= target.
  let lo = 0;
  let hi = cumulative.length - 1;
  while (lo < hi) {
    const mid = (lo + hi + 1) >> 1;
    if (cumulative[mid] <= target) lo = mid;
    else hi = mid - 1;
  }
  // ...then lerp within the segment
}

Source: amirsaeed671/MatchReady, lib/geometry.ts

There's more care in the details. The code adds phantom control points (mirrored across each endpoint) so the first and last segments get proper tangents, and it snaps the final sample exactly onto the last waypoint. Easing follows coaching intuition: balls use easeOut (cubic deceleration) and players use easeInOut. When a player dribbles, the ball sits 18 pitch units ahead along the path's tangent, which is a nice touch for a drill tool.

Keeping React out of the hot path

Canvas apps built on React often slow down because every frame goes through setState. In components/usePlayback.ts, Amir writes positions straight to Konva nodes inside one requestAnimationFrame loop:

const positions = positionsAt(compiledRef.current, t);
for (const [id, p] of positions) {
  const node = stage.findOne("#" + id);
  if (node) node.position(p);
}
stage.batchDraw();

Source: amirsaeed671/MatchReady, components/usePlayback.ts

React re-renders only the timeline scrubber. The comment in the file calls this "the smoothness requirement; treat it as non-negotiable".

Two smaller decisions are also worth copying:

  • Undo covers the document only. zundo's partialize: (state) => ({ drill: state.drill }) means undo never reverts a tool switch or a selection change, only real edits.
  • GIF encoding runs off the main thread in a Web Worker, so the UI stays responsive during export.

The repo also has a npm run validate script that checks engine invariants and runs a real GIF encode headlessly, which acts as a regression guard for the maths.


Part 2: Smart Doc, where saving is safe even with collaborators

Smart Doc is a small Google Docs-style editor. You can create documents, share them with Viewer, Commenter or Editor roles, see who else is in the document, leave comments on selected text, browse version history and restore versions, and export to Markdown or PDF. The demo has seeded accounts you can try (the README lists them).

According to SUBMISSION.md, he built it as a full-stack developer assignment. The design notes in ARCHITECTURE.md explain his priorities. He put the effort into "trusting that what you wrote is saved and that collaborators see the same thing", not into a long feature list.

Stack: Next.js 15, React 19, TypeScript, TipTap 3 (ProseMirror), Postgres through Supabase in production or PGlite (Postgres compiled to WASM) locally, Supabase Realtime, and signed-cookie sessions with jose.

Optimistic locking instead of last-write-wins

The easy way to build a collaborative editor is to let the last save win, which means someone loses their work without being told. Smart Doc gives every document a version number and rejects stale writes:

const updated = await db.query(
  `UPDATE documents
     SET content = $2::jsonb, version = version + 1, updated_at = now(), updated_by = $3
   WHERE id = $1 AND version = $4
   RETURNING version, title`,
  [docId, JSON.stringify(content), userId, input.baseVersion],
)
if (updated.length === 0) {
  const latest = await getDocument(db, userId, docId)
  throw conflict('This document was changed by someone else since you started editing.', { latest })
}

Source: amirsaeed671/smart-doc, src/lib/documents.ts

The API returns a 409 that includes the latest document, and the UI asks the user to choose "load theirs" or "keep mine". Renames deliberately don't bump the version, so changing a title never causes a false conflict.

One permission table for the API and the UI

const RANK: Record<Role, number> = { viewer: 0, commenter: 1, editor: 2, owner: 3 }
const REQUIRED: Record<Capability, Role> = { view: 'viewer', comment: 'commenter', edit: 'editor', manage: 'owner' }

export const can = (role: Role, capability: Capability) => RANK[role] >= RANK[REQUIRED[capability]]

Source: amirsaeed671/smart-doc, src/lib/permissions.ts

The server enforces access with can(), and the UI uses the same function to decide which buttons to show, so the UI can't offer an action the server would refuse. Documents you can't access return 404 rather than 403, so the API doesn't reveal that they exist.

Other good calls

  • Realtime sends signals, not data. Peers broadcast messages like "I saved version N", and other clients re-fetch through the authenticated API. A guessed channel name exposes a version number and nothing more.
  • Content is validated against the editor schema on the server (Node.fromJSON(schema).check()). The same parse cleans imported HTML, so <script> tags never reach the database.
  • Comments are anchored by quoted text and drawn as decorations. Storing them as marks inside the document would mean adding a comment edits the document, which a Commenter isn't allowed to do.
  • Row-level security is on with no policies for Supabase, so the server API is the only way to reach the data.

It's also tested. Vitest suites run against in-memory Postgres, and a Playwright smoke test drives three browsers (Alice, Bob and Carol) through sharing, live sync, an edit conflict, revocation and version restore.

The part I respect most is his openness about AI. AI_WORKFLOW.md says he used Claude Code and lists what he rejected: last-write-wins saving and sending document content over the realtime socket. It also lists the bugs that end-to-end tests caught in generated code and how he fixed them. One example: TipTap's setEditable() emitted an update event, so simply opening a document triggered a save. That's the kind of review discipline you want from someone who uses AI tools.


What these projects show

Leaving the feature lists aside, both repos show the same habits:

  • Choosing the hard problem on purpose: deterministic playback in one app, safe concurrency in the other.
  • Explaining trade-offs in writing. Smart Doc's architecture note includes a "What I deliberately did not build" section (for example, CRDT co-editing) and explains why.
  • Treating performance and correctness as requirements, from the rAF loop that bypasses React to the headless validation script.
  • Testing the risky parts, not only the easy ones.

His broader GitHub shows the same direction. fun-board (demo) is a multi-tenant leaderboard with Elo ratings, built on Next.js 16, Auth.js, Zod and Turso. Newer repos such as agentflow and syncspace-ai are early Go + React scaffolds for AI products. His GitHub profile lists him at Healf, and his portfolio has more projects.


Work with Amir

If you need someone who can build a Next.js/TypeScript product end to end and write down the reasoning behind it, Amir is open to work.

Even if you're not hiring, the MatchReady and Smart Doc repos are worth reading.

來源:DEV Community · dev.to