我在每個加入過的團隊裡,都會發生這件事。

Sprint 結束了。有人排了回顧會。另一個人把 Miro 連結貼到 Slack。半個團隊打開之後,立刻就撞上一堵牆。「我是檢視者方案。」「我這邊載不出來。」「貼上便條貼要怎麼做來著?」有人不小心刪掉了整個框架。還有人視角還停在看板的錯誤角落,根本不知道怎麼回來。

我們每次回顧會前 10 到 15 分鐘,都只是在修看板。

而我全程坐在那裡想:這不就是一塊便條貼看板嗎?這是會議史上最古老、最簡單、最直觀的工具了。到底怎麼會發展到需要教學才能使用?

所以我做了一個東西。它叫 PostItUp。這是一個在瀏覽器中執行的即時協作便條貼看板。外觀像真的軟木公告板。任何人都可以不用註冊帳號直接貼上便條。整個服務也完全免費。

我想帶你完整看一遍:產品本身、設計決策、技術選擇、差點把我逼瘋的 bug,以及接下來的方向。這篇會很長。先拿點飲料來。


這東西到底是什麼

PostItUp landing page

概念很簡單。你建立一個看板,分享連結,大家點進去後就能立刻開始貼便條。不用註冊、不用教學、不用 onboarding 流程。

每張便條就是一張便利貼。你選顏色、輸入內容、可選擇加上自己的名字,然後按下「Pin it」。便條就會出現在畫布上。所有正在看同一個看板的人,都會即時看到它出現。

這就是核心流程。其他功能都只是建立在這之上的細節。

視覺設計是刻意做的,而且比看起來更重要。每張卡片都有手繪感的波浪邊框。便條上有小塊和紙膠帶把它固定在看板上。圖釘標示專案卡片。背景則會依你選擇,顯示為點陣格、橫線筆記頁或方格樣式。字體也是真的手寫字體,而且在小尺寸下仍保持可讀。

我故意把它做成這樣。等一下我會解釋原因。


為什麼要把它做成這樣

大多數協作工具看起來都像 SaaS 儀表板:乾淨、扁平、高效、但也有點冷。這種風格用在正式專案管理上沒有問題。

但如果是快速回饋、回顧會、腦力激盪,這種視覺語言其實是在跟你作對。它會傳達出「這是正式場合」的訊號,讓大家寫東西時變得更謹慎、更保留。那些會把很直白的 Post-it 貼在實體看板上的人,到了 Jira ticket 裡,往往就會寫得委婉得多。

實體便利貼給人的感覺是可丟棄的、隨手的、安全的,可以誠實。我想讓數位版也帶有同樣的感受。

另外,工具「長得不一樣」這件事也很重要。當你整週都盯著同一份 Notion 文件或同一個 Miro 看板時,會開始有點麻木。打開一個看起來像螢幕上的軟木公告板的東西,會讓腦袋產生一個很小但明顯的情境切換。我覺得這雖然是小事,但確實有意義。


三種畫布模式,因為一種模式從來不夠用

New board creation page

建立看板時,你可以選三種畫布模式之一。

自由畫布 是開放式點陣格背景。便條可以放在任何地方。你可以用 Alt+拖曳或滑鼠中鍵平移,用 Ctrl+捲動縮放。便條想放哪就放哪。這是最彈性的模式,也最混亂。

方格模式 會自動把所有東西對齊到 32 像素網格。畫布依然完全開放,但便條會自動對齊,不需要你手動整理。這是我現在預設最常用的模式。結構夠明確,能保持可讀性,又不會讓人覺得被限制。

橫線模式 會在背景上加上橫向筆記本線條。整個看板的氛圍都不一樣了。這個模式很適合順序型回饋,或者你在收集有順序的清單,而不是自由發想時使用。

看板擁有者在建立時就先決定模式,而且會套用到整個畫布。底層是同一套程式碼、同一批元件,但體驗會完全不同。

我在這個決策上花的時間,比原本預期多很多。一開始我直覺是「就做自由畫布吧,這最合理」。但我拿去做了幾場快速回饋會後,發現不太熟悉開放畫布的人總是貼得很彆扭,不知道該放哪。方格模式把這個問題完全解掉了。而橫線模式則是因為有人說,他們希望它更像問卷。

功能應該來自觀察人怎麼使用,而不是來自你想像他們可能需要什麼。


貼一張便條

在畫布任何地方雙擊。會跳出一個對話框。輸入內容、選顏色。如果想的話可以加上名字(它會把你的偏好存到 localStorage,所以只要輸入一次)。然後按「Pin it」。

便條就會出現在畫布上。

如果有其他人正在看同一個看板,他們會立刻看到便條出現。不用重新整理。這個看板是即時的。

Board canvas with notes

便條在落點時會有些微隨機旋轉。介於負 3 度到正 3 度之間,在插入時隨機挑選並存進資料庫。這是個很小的細節,但能讓看板看起來更像人貼上去的,而不是軟體生成的。整齊劃一的便利貼貼在軟木板上反而會怪怪的,所以我沒那樣做。

你可以拖曳便條在畫布上移動。放開滑鼠的瞬間,位置就會儲存到資料庫。所有觀看中的人都會看到它移動。

你可以替你認同的便條按讚。每個裝置對每張便條只能投一次票。票數會永久保留在便條上。

看板擁有者可以刪除任何便條。作者可以刪除自己的便條。擁有者也可以從設定中清空整個看板。


我覺得開發者最會用到的部分

Embed panel in board settings

每個看板都有一個嵌入面板。你可以從工具列的連結圖示按鈕打開它。有三種選項。

iFrame 是最直接的方式。只要一行,就能把你的看板嵌入到任何網頁中。

<iframe 
  src="https://postitup.varshithvhegde.in/embed/your-board-slug"
  width="100%" 
  height="600" 
  frameborder="0">
</iframe>

這裡有一條獨立的 /embed/[slug] 路由,會呈現精簡版畫布,沒有導覽列或應用程式外框。只有看板本身。Supabase Realtime 仍然會在裡面運作,所以嵌在 iframe 內也會即時更新。

Script 標籤 才是我真正覺得實用的方式。把這段放進任何網頁:

<script 
  src="https://postitup.varshithvhegde.in/embed.js"
  data-board="your-board-slug"
  data-url="https://postitup.varshithvhegde.in">
</script>

它會在角落注入一個浮動的「Leave a note」按鈕。點下去後,會滑出一個抽屜,裡面就是完整看板。只有在真的有人點按鈕時,iframe 才會載入。如果沒人打開抽屜,這個看板幾乎不消耗任何資源。沒有網路請求、沒有版面跳動,什麼都沒有。

這支 script 是完全自包含的。你的頁面端不需要任何框架。靜態 HTML 頁、WordPress 部落格、Next.js 應用程式,都能用。我已經在幾個頁面上用過,整個設定大概 90 秒就完成。

React 元件 則是下一步。

目前這段範例是在說明正式發布後的用法。npm 套件還沒上架,但已經在進行中。目標會像這樣:

import { PostItBoard } from "postitup"

<PostItBoard
  board="your-board-slug"
  baseUrl="https://postitup.varshithvhegde.in"
  height={500}
/>

有 TypeScript 型別、支援 SSR、在 Next.js 裡不會有 hydration 問題,還有 theme 屬性,讓它在你的應用裡看起來不會格格不入。如果你會用到這個功能,記得關注專案。它快來了。


接下來是技術部分

技術堆疊:Next.js 16 搭配 App Router,Supabase 負責資料庫、即時同步與驗證,全面使用 TypeScript,Tailwind 用於版面工具。沒有元件庫,所有視覺效果都是手寫 CSS。

波浪邊框怎麼做出來的

卡片上的手繪感來自頁面 layout 裡定義的一個 SVG filter:

<filter id="roughen">
  <feTurbulence 
    type="fractalNoise" 
    baseFrequency="0.04" 
    numOctaves="4" 
    seed="3" 
    result="noise" />
  <feDisplacementMap 
    in="SourceGraphic" 
    in2="noise" 
    scale="2.5" 
    xChannelSelector="R" 
    yChannelSelector="G" />
</filter>

位移貼圖會根據分形雜訊去偏移像素,結果就像有人手繪邊框一樣。只要對任何元素套用 filter: url(#roughen),它就會有波浪感。這個 filter 只定義一次,到處重複引用。

和紙膠帶條則是半透明的 div,加上重複線性漸層做出紋理:

background-image: repeating-linear-gradient(
  90deg,
  transparent 0, transparent 3px,
  rgba(255,255,255,0.18) 3px, rgba(255,255,255,0.18) 4px
);
mix-blend-mode: multiply;

mix-blend-mode: multiply 會讓它看起來像真的半透明膠帶,能和紙張底色自然融合。這些小細節加總起來效果就出來了。

Supabase 即時同步

Supabase Realtime 是建立在 Postgres 上的 WebSocket 層。你可以訂閱某個資料表的變更事件並加上篩選條件,當列資料改變時,Supabase 就會把 payload 推給你。

const channel = supabase
  .channel(`board:${board.id}`)
  .on("postgres_changes", {
    event: "INSERT",
    schema: "public",
    table: "notes",
    filter: `board_id=eq.${board.id}`
  }, (payload) => {
    setNotes(n =>
      n.find(x => x.id === payload.new.id)
        ? n  // already have it from optimistic update
        : [...n, payload.new as Note]
    )
  })
  .on("postgres_changes", { event: "UPDATE", ... }, (payload) => {
    setNotes(n => n.map(x =>
      x.id === payload.new.id ? { ...x, ...payload.new } : x
    ))
  })
  .on("postgres_changes", { event: "DELETE", ... }, (payload) => {
    setNotes(n => n.filter(x => x.id !== payload.old.id))
  })
  .subscribe()

INSERT 處理器會先檢查這張便條是否已經存在,再決定要不要新增。當你貼出一張便條時,你自己的 UI 會先做樂觀更新。即時事件稍後才會到。如果沒有這個檢查,你會看到便條出現兩次。

那個浪費我整個下午的拖曳 bug

拖曳便條在畫面上看起來是正常的,但位置沒有正確存檔。重新整理頁面後,便條又跳回原本的位置。

問題出在 stale closure。滑鼠放開的處理器讀到的是 React state 的快照,而那個快照是在 callback 第一次建立時就固定的,不是拖曳結束後的最新位置。

React 的 state 更新是非同步的。等 mouseup 觸發時,你在建立 handler 當下閉包住的 state,可能已經落後好幾次 render。畫面上看起來有移動,但你寫進資料庫的其實是舊值。

解法是把即時位置存到 ref 裡,並在每次 mousemove 時更新:

const dragging = useRef<{
  id: string
  ox: number      // original position
  oy: number
  startX: number  // mouse start
  startY: number
  finalX: number  // updated every mousemove
  finalY: number
} | null>(null)

// in onMouseMove:
const nx = snap(dragging.current.ox + dx, board.mode)
const ny = snap(dragging.current.oy + dy, board.mode)
dragging.current.finalX = nx
dragging.current.finalY = ny
setNotes(ns => ns.map(n =>
  n.id === dragging.current?.id ? { ...n, x: nx, y: ny } : n
))

// in onMouseUp:
const { id, finalX, finalY } = dragging.current
dragging.current = null  // clear before the async call
await supabase.from("notes").update({ x: finalX, y: finalY }).eq("id", id)

Ref 是可變的,而且不管 closure 是什麼時候建立的,它都會提供最新值。ref 裡的便條位置就是 mouseup 觸發當下的實際最終位置。問題解決。

Row Level Security 與投票問題

Supabase 使用 Postgres Row Level Security。每張資料表上的 policy 會控制每個使用者能讀、能新增、能更新、能刪哪些資料。如果不設這些,任何拿到 anon key 的人都能碰你的資料庫,而 anon key 會嵌在前端 bundle 裡,完全公開。

大部分 policy 都很直接,但投票這條就不是了。

我需要阻止客戶端直接把 upvotes 欄位設成任意數字。我的第一個嘗試是在 with check 裡加條件,去把欄位值跟 notes 表裡的子查詢相比:

create policy "update notes" on notes
  for update using (...)
  with check (
    upvotes = (select upvotes from notes where id = notes.id)
  )

結果造成無限遞迴。Postgres 嘗試評估這條 policy,而 policy 又讀取 notes 表;讀 notes 表又會觸發 policy;然後 policy 又去讀 notes 表,如此循環。資料庫層級的堆疊溢位。

真正的解法是用一個 SECURITY DEFINER 函式來處理整個投票流程:

create or replace function increment_upvote(note_id uuid, voter_fp text)
returns json
language plpgsql
security definer
as $$
declare
  already_voted boolean;
  new_count integer;
begin
  select exists(
    select 1 from note_votes 
    where note_votes.note_id = increment_upvote.note_id
    and voter_fingerprint = voter_fp
  ) into already_voted;

  if already_voted then
    return json_build_object('success', false, 'reason', 'already_voted');
  end if;

  insert into note_votes (note_id, voter_fingerprint)
  values (increment_upvote.note_id, voter_fp);

  update notes set upvotes = upvotes + 1
  where id = increment_upvote.note_id
  returning upvotes into new_count;

  return json_build_object('success', true, 'upvotes', new_count);
end;
$$;

SECURITY DEFINER 的意思是這個函式會用資料庫擁有者的權限執行,而不是呼叫者的權限。對 note_votes 的直接插入會被 policy 擋下。要記錄票數,唯一方法就是呼叫這個函式。函式會檢查重複投票,並以原子方式增加票數。沒有人能從客戶端直接操控 upvotes 欄位。

位置怎麼存到資料庫

每張便條都有 xywidthrotation 欄位。這些欄位都是浮點數。當你拖動便條並放開滑鼠時,會送出一次資料庫更新,把新座標寫進去。其他正在看這個看板的人,Supabase Realtime 會送來 UPDATE 事件,他們畫面上的便條就會跟著移動。

rotation 只會在插入時設定一次。隨機值介於負 3 度到正 3 度之間,並永久存起來。之後不再變動。這就是看板看起來像真實軟木板,而不是格線的原因。

在方格模式下,座標在儲存前會先對齊到最近的 32 像素增量:

function snap(v: number, mode: Board["mode"]) {
  return mode === "grid" ? Math.round(v / GRID) * GRID : v
}

自由模式和橫線模式則完全跳過對齊。這個函式是純函式,在 mousemove 處理器中用來即時預覽,並且在寫入資料庫前再跑一次,確保你看到的就是最後存下來的內容。

輸入驗證在前後端都做

所有內容在碰資料庫之前都會先經過清理器:

export function sanitizeText(input: string): string {
  return input
    .replace(/<[^>]*>/g, "")       // strip HTML tags
    .replace(/javascript:/gi, "")  // kill JS URIs
    .trim()
}

字數限制也會在 onChange handler 裡先檢查,讓你連提交超長內容都無法嘗試。最後還有資料庫欄位層級的約束作為保底:

alter table notes
  add constraint notes_content_length 
    check (char_length(content) between 1 and 500),
  add constraint notes_upvotes_nonneg 
    check (upvotes >= 0)

就算有人完全繞過前端,直接送原始 API 請求,資料庫還是會拒絕任何違反規則的內容。兩層防護,彼此獨立。

GDPR 相關

我希望這個服務是讓人能信任的。所以我一開始就把完整的資料權利做進去,而不是等到之後覺得麻煩再補。

有兩個 Postgres 函式負責這件事。

資料匯出會把我們持有、關於你的所有資料以 JSON 形式回傳。帳號頁面會把它下載成檔案。按一下,你就拿到自己的資料。這滿足 GDPR 第 20 條。

帳號刪除比較複雜。它需要刪除你所有的看板(透過外鍵級聯刪除便條)、將你在別人看板上貼的便條匿名化(內容保留、歸屬資訊移除)、刪除你的個人資料,最後再刪除驗證紀錄。最後一步需要較高權限,所以這個函式以 SECURITY DEFINER 執行。完成資料庫操作後,客戶端會清除 localStorage 並登出。

整個清除。什麼都不留。


驗證

使用 Supabase 的 GitHub OAuth。你如果偏好,也可以用 email 和密碼。

當有人註冊時,Postgres trigger 會自動建立一筆 profile 記錄:

create or replace function handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, email, display_name)
  values (
    new.id,
    coalesce(new.email, ''),
    coalesce(
      new.raw_user_meta_data->>'display_name',
      split_part(coalesce(new.email, 'anonymous'), '@', 1)
    )
  )
  on conflict (id) do nothing;
  return new;
end;
$$ language plpgsql security definer;

on conflict do nothing 可以避免 trigger 因為某種原因對同一個使用者執行兩次時發生錯誤。這件事在測試期間不只發生過一次,所以有這段我很慶幸。

路由保護則是用 Next.js middleware,在提供受保護頁面之前檢查 Supabase session。如果沒有 session,就會被導回登入頁,並把預期目的地當成 query parameter 帶回去。登入後你會回到原本想去的頁面。

匿名使用者可以在公開看板和僅限連結的看板上直接發文,完全不需要帳號。他們的便條作者名稱會從 localStorage 取得;票數則由一個同樣來自 localStorage 的隨機指紋追蹤。沒有任何東西綁定到身份。資料庫裡除了便條本身,沒有其他相關資料。


看板:完整流程怎麼運作

你會在 /new 建立看板:標題、描述、給貢獻者的提示文字、畫布模式、可見度。送出後會導向 /board/your-slug

slug 會從看板標題加上一個四位數隨機後綴產生,用來避免衝突。像 sprint-retro-3a7f,而不是只有 sprint-retro。簡單又好讀。

看板頁面是 Next.js server component,會在伺服器端先抓看板資料與初始便條。這對效能很重要:頁面載入時,畫布上已經有內容了。不會先看到 loading spinner,也不會看到空白看板過一下才填入。便條直接就在 HTML 裡。

完成初次載入後,就交給 Supabase Realtime 負責之後的所有更新。兩套系統各自做自己最擅長的事。

看板設定讓擁有者可以更新標題、描述、提示文字、畫布模式與可見度。從自由模式切到方格模式不會移動既有便條,只會讓新便條開始自動對齊。變更可見度會立即生效。

刪除看板時必須輸入看板標題確認。所有便條都會級聯刪除。不可復原。這個確認機制是故意設得麻煩一點。


接下來會做什麼

npm 套件是我目前最專注的部分。

iframe 嵌入已經很好用了,但如果你本來就在 React 專案裡,直接丟一個元件進去,體驗會乾淨得多。計畫會是一個 <PostItBoard />,提供 TypeScript API、theme 屬性讓它在你的 UI 裡不顯得突兀,還有 SSR 安全性,確保在 Next.js 裡不會出現 hydration 警告。

import { PostItBoard } from "postitup"

<PostItBoard
  board="your-board-slug"
  baseUrl="https://postitup.varshithvhegde.in"
  height={500}
  theme="paper"
/>

大概就是這個樣子。很快就會發布到 npm。如果你會用到,記得關注 GitHub 倉庫。

再來是:lane 模式。欄位式、Kanban 風格的版面,讓你可以做像產品回饋的 Liked / Meh / Disliked,或是回顧會的 What Went Well / What Didn't / Action Items。還是同樣的即時同步、同樣的匿名貼文,只是從自由畫布改成欄位整理。

還有 看板範本,讓你每次要開回顧會時,不用從零開始。


免費,而且是真的免費

整個服務跑在 Supabase 免費方案和 Vercel hobby 方案上。

Supabase 免費版:500MB 資料庫、50,000 每月活躍使用者、無限 API 請求。
Vercel hobby:無限部署、免費網域、快速邊緣網路。

你拿來跟團隊做回顧會,根本碰不到這些限制。

這跟我之前寫過的 FormRelay 是同樣的道理。很多人之間有一道很大的鴻溝:一邊是「自己架伺服器」,另一邊是「每月付 20 美元,只為了做一筆資料庫 insert」。很多每個月花掉真金白銀的問題,其實只是偽裝成週末專案而已。

https://dev.to/varshithvhegde/i-built-a-form-backend-in-a-weekend-because-paying-20month-for-contact-forms-is-stupid-1o34

一個能即時更新的便條貼看板聽起來很複雜。其實不然。它就是一個資料表、一個 WebSocket 訂閱、以及一個知道怎麼拖動元素的畫布。真正有意義的檔案加起來,大概兩千行程式碼。你大概可以在一個下午把整個 repo 看完。


試試看

線上版:postitup.varshithvhegde.in

原始碼:github.com/Varshithvhegde/postitup
{% github Varshithvhegde/postitup

建立一個看板,分享連結給別人,然後看便條即時出現。

如果有地方壞掉,或你有功能想法,歡迎開 issue。如果你想貢獻,PR 也都開放。採 MIT 授權,所以你想怎麼用都可以。

如果你最後真的把嵌入功能用在某個地方,我會非常想看看。可以在留言區貼給我,或寄信到 [email protected]



原文出處:https://dev.to/varshithvhegde/i-got-sick-of-miro-eating-10-minutes-of-every-retro-so-i-built-a-corkboard-for-the-web-41n9


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝6   💬2   ❤️1
179
🥉
我愛JS
💬1  
3
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登