diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml new file mode 100644 index 0000000..9a19dfa --- /dev/null +++ b/.github/workflows/deploy-main.yml @@ -0,0 +1,42 @@ +name: Deploy Main + +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: deploy-main + cancel-in-progress: false + +jobs: + deploy: + name: Build and deploy affected apps + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build and deploy affected apps + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + run: | + node tools/netlify/deploy-affected.mjs \ + --mode production \ + --base "${{ github.event.before }}" \ + --head "${{ github.sha }}" diff --git a/.github/workflows/deploy-pr-previews.yml b/.github/workflows/deploy-pr-previews.yml new file mode 100644 index 0000000..a7f68b1 --- /dev/null +++ b/.github/workflows/deploy-pr-previews.yml @@ -0,0 +1,89 @@ +name: Deploy PR Previews + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: netlify-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + deploy-preview: + name: Build and deploy affected previews + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build and deploy affected previews + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + node tools/netlify/deploy-affected.mjs \ + --mode preview \ + --base "origin/${{ github.base_ref }}" \ + --head HEAD \ + --pr "${{ github.event.pull_request.number }}" + + - name: Comment preview links + if: always() + uses: actions/github-script@v8 + with: + script: | + const fs = require("fs") + const marker = "" + const path = "netlify-deployments.md" + const summary = fs.existsSync(path) + ? fs.readFileSync(path, "utf8") + : "Netlify preview deployment did not produce a summary." + const body = `${marker}\n${summary}` + const { owner, repo } = context.repo + const issue_number = context.payload.pull_request.number + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number, + per_page: 100, + }) + const existing = comments.data.find((comment) => + comment.user?.type === "Bot" && comment.body?.includes(marker) + ) + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }) + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }) + } diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml deleted file mode 100644 index 62ee5b6..0000000 --- a/.github/workflows/github-pages.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: GitHub Pages - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - id-token: write - pages: write - -concurrency: - group: github-pages - cancel-in-progress: false - -jobs: - build: - name: Build Pages artifact - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "23" - cache: npm - cache-dependency-path: package-lock.json - - - run: npm ci - - - name: Build static apps with source maps - run: npm run build:pages - - - uses: actions/configure-pages@v5 - with: - enablement: true - - - uses: actions/upload-pages-artifact@v4 - with: - path: pages-dist - - deploy: - name: Deploy Pages - runs-on: ubuntu-latest - needs: build - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/replay-mcp-lab.yml b/.github/workflows/replay-mcp-lab.yml deleted file mode 100644 index 7f9ebb1..0000000 --- a/.github/workflows/replay-mcp-lab.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: Replay MCP Lab - -on: - push: - branches: [main, changesets-main] - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - checks: - name: Build and normal Playwright checks - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "23" - cache: npm - cache-dependency-path: package-lock.json - - - run: npm ci - - - name: Install Playwright Chromium - run: npx playwright install --with-deps chromium - - - run: npm run typecheck - - run: npm run build - - run: npm run test - - run: npm run validate:manifests - - - name: Upload Playwright artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-normal-checks - path: | - replay-mcp-lab-vite/playwright-report - replay-mcp-lab-vite/test-results - replay-mcp-lab-next/playwright-report - replay-mcp-lab-next/test-results - replay-mcp-lab-tanstack-start/playwright-report - replay-mcp-lab-tanstack-start/test-results - if-no-files-found: ignore - - replay-recordings: - name: Replay recordings (${{ matrix.framework }}) - runs-on: ubuntu-latest - needs: checks - strategy: - fail-fast: false - matrix: - include: - - framework: vite - workspace: "@replayio/mcp-lab-vite" - package-dir: replay-mcp-lab-vite - - framework: next - workspace: "@replayio/mcp-lab-next" - package-dir: replay-mcp-lab-next - - framework: tanstack-start - workspace: "@replayio/mcp-lab-tanstack-start" - package-dir: replay-mcp-lab-tanstack-start - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "23" - cache: npm - cache-dependency-path: package-lock.json - - - run: npm ci - - - name: Require Replay API key - env: - REPLAY_API_KEY: ${{ secrets.SANDBOX_CI_REPLAY_API_KEY || secrets.REPLAY_API_KEY }} - run: | - if [ -z "$REPLAY_API_KEY" ]; then - echo "Configure SANDBOX_CI_REPLAY_API_KEY or REPLAY_API_KEY for Replay uploads." - exit 1 - fi - - - name: Install Playwright Chromium dependencies - run: npx playwright install --with-deps chromium - - - name: Install Replay browser - run: npx replayio install - - - name: Show Replay runtime info - run: npx replayio info - - - name: Run Replay Playwright recordings - env: - REPLAY_API_KEY: ${{ secrets.SANDBOX_CI_REPLAY_API_KEY || secrets.REPLAY_API_KEY }} - REPLAY_RUN_TITLE: replay-mcp-lab-${{ matrix.framework }}#${{ github.run_number }}.${{ github.run_attempt }} - REPLAY_UPLOAD: "1" - RECORD_ALL_CONTENT: "1" - RECORD_REPLAY_TEST_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.framework }} - RECORD_REPLAY_VERBOSE: "1" - run: npm run record:all -w ${{ matrix.workspace }} - - - name: Validate manifest coverage - run: npm run validate:manifest -w ${{ matrix.workspace }} - - - name: Upload Playwright artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-replay-${{ matrix.framework }} - path: | - ${{ matrix.package-dir }}/playwright-report - ${{ matrix.package-dir }}/test-results - if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 0aa53e8..86f626f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ +.DS_Store +.env +.env.* +!.env.example + node_modules/ dist/ -out/ -pages-dist/ -.next/ -.output/ -test-results/ -playwright-report/ -tsconfig.tsbuildinfo -.DS_Store +.nx/ +.netlify/ + +coverage/ +*.tsbuildinfo +netlify-deployments.md diff --git a/README.md b/README.md index 4c0b14c..d36902e 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,47 @@ -# Replay MCP Lab Examples +# ExampleApps -This workspace contains one shared Replay MCP diagnostic lab and three thin framework shells: +Nx monorepo for the Replay example apps. -- `replay-mcp-lab-core`: shared scenarios, state stores, test runners, API fixtures, and manifest scripts. -- `replay-mcp-lab-vite`: Vite React shell. -- `replay-mcp-lab-next`: Next.js App Router shell. -- `replay-mcp-lab-tanstack-start`: TanStack Start shell. +## Apps -Install from this directory: +| Project | Production URL | +|---|---| +| `acctual` | | +| `blamy-notes` | | +| `digg-clone` | | +| `github-clone` | | +| `linear` | | +| `pampam-clone` | | +| `substack-clone` | | +| `todoist-clone` | | + +## Local commands ```bash npm install +npm run build:all +npx nx run todoist-clone:dev ``` -Run one shell: +Each app keeps its own Vite, TypeScript, and Netlify Functions config under +`apps/`. -```bash -npm run dev -w @replayio/mcp-lab-vite -npm run dev -w @replayio/mcp-lab-next -npm run dev -w @replayio/mcp-lab-tanstack-start -``` +## Deployments + +Netlify site IDs live in `tools/netlify/sites.json`. -Recordings are generated per shell with: +The deploy helper uses Nx affected-project detection: ```bash -npm run record:all -w @replayio/mcp-lab-vite -npm run record:all -w @replayio/mcp-lab-next -npm run record:all -w @replayio/mcp-lab-tanstack-start +node tools/netlify/deploy-affected.mjs --mode production --base origin/main --head HEAD +node tools/netlify/deploy-affected.mjs --mode preview --base origin/main --head HEAD --pr 123 ``` -The CI workflow at `.github/workflows/replay-mcp-lab.yml` runs the normal -Chromium checks first, then runs each shell's Replay Chromium recording suite in -a matrix job. Configure either `SANDBOX_CI_REPLAY_API_KEY` or `REPLAY_API_KEY` -as a GitHub Actions secret. The recording jobs install the Replay browser with -`npx replayio install`, set `REPLAY_UPLOAD=1`, and upload through -`replayReporter({ apiKey: process.env.REPLAY_API_KEY, upload: { statusThreshold: "all" } })`. +The GitHub Actions workflows require a repository secret named +`NETLIFY_AUTH_TOKEN`. -Each shell owns a `recordings.manifest.json` file. The manifest starts with placeholder recording IDs and can be refreshed after upload with each shell's `upload:recordings` script. +- `Deploy Main` runs on pushes to `main`, builds affected app projects, uploads + draft deploys, and publishes them to the configured production Netlify sites. +- `Deploy PR Previews` runs on same-repository pull requests, deploys affected + apps to stable `deploy-preview-` Netlify aliases, and updates a PR comment + with the preview links. diff --git a/apps/acctual/.gitignore b/apps/acctual/.gitignore new file mode 100644 index 0000000..b105e56 --- /dev/null +++ b/apps/acctual/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Local Netlify folder +.netlify + +# Playwright MCP artifacts +.playwright-mcp diff --git a/apps/acctual/.prettierignore b/apps/acctual/.prettierignore new file mode 100644 index 0000000..0b4a1db --- /dev/null +++ b/apps/acctual/.prettierignore @@ -0,0 +1,7 @@ +node_modules/ +coverage/ +.pnpm-store/ +pnpm-lock.yaml +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/apps/acctual/.prettierrc b/apps/acctual/.prettierrc new file mode 100644 index 0000000..9000bfa --- /dev/null +++ b/apps/acctual/.prettierrc @@ -0,0 +1,11 @@ +{ + "endOfLine": "lf", + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80, + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindStylesheet": "src/index.css", + "tailwindFunctions": ["cn", "cva"] +} diff --git a/apps/acctual/README.md b/apps/acctual/README.md new file mode 100644 index 0000000..67e5fdb --- /dev/null +++ b/apps/acctual/README.md @@ -0,0 +1,45 @@ +# Acctual Clone + +An **Acctual.app clone** — invoicing and payments software for freelancers and small businesses. Built with **Vite + React 19 + shadcn/ui** and a **Netlify Functions** in-memory API. + +Based on the Acctual web UI reference (Nov 2025). + +## Stack + +- Vite + React 19, TypeScript, Tailwind v4, shadcn/ui +- **Zustand** — navigation and modal state +- **TanStack Query** — server state +- **Netlify Functions** — REST API for invoices, bills, contacts, payments + +## Features + +- Light Acctual-inspired UI (gray sidebar, white content cards) +- **Invoices** — tabbed list (Draft, Unpaid, Overdue, Paid), create-invoice wizard with live preview, view/send/update +- **Payments** — Receive/Transfer tabs, setup cards, transaction history +- **Bills** — tabbed AP workflow (Draft → Approve → Ready → Paid) +- **Contacts** — contact list with add-contact drawer +- Crypto + fiat payment methods (USDT, USD ACH, etc.) + +## Run it + +```bash +cd acctual +npm install +npm start # = netlify dev → http://localhost:8888 +``` + +Front end only: `npm run dev`. + +## Layout + +``` +netlify/functions/api.mts # REST API +netlify/functions/lib/data.ts # seed data +src/components/invoices-view.tsx # invoice list + tabs +src/components/create-invoice-dialog.tsx +src/components/view-invoice-dialog.tsx +src/components/invoice-preview.tsx # live document preview +src/components/payments-view.tsx +src/components/bills-view.tsx +src/components/contacts-view.tsx +``` diff --git a/apps/acctual/components.json b/apps/acctual/components.json new file mode 100644 index 0000000..5c23ec4 --- /dev/null +++ b/apps/acctual/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/acctual/eslint.config.js b/apps/acctual/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/apps/acctual/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/apps/acctual/index.html b/apps/acctual/index.html new file mode 100644 index 0000000..1f78584 --- /dev/null +++ b/apps/acctual/index.html @@ -0,0 +1,13 @@ + + + + + + + Acctual + + +
+ + + diff --git a/apps/acctual/netlify.toml b/apps/acctual/netlify.toml new file mode 100644 index 0000000..904c806 --- /dev/null +++ b/apps/acctual/netlify.toml @@ -0,0 +1,10 @@ +[build] + command = "npm run build" + publish = "dist" + functions = "netlify/functions" + +[dev] + command = "npm run dev" + targetPort = 5173 + port = 8888 + framework = "#custom" diff --git a/apps/acctual/netlify/functions/api.mts b/apps/acctual/netlify/functions/api.mts new file mode 100644 index 0000000..068794e --- /dev/null +++ b/apps/acctual/netlify/functions/api.mts @@ -0,0 +1,108 @@ +import type { Config, Context } from "@netlify/functions" +import { + invoices, + bills, + contacts, + paymentMethods, + transactions, + profile, + makeId, + type Invoice, + type Bill, + type Contact, +} from "./lib/data.ts" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +export default async (req: Request, _context: Context) => { + const url = new URL(req.url) + const path = url.pathname.replace(/^\/api/, "") || "/" + const method = req.method + + if (path === "/profile" && method === "GET") return json(profile) + + if (path === "/contacts" && method === "GET") return json(contacts) + + if (path === "/contacts" && method === "POST") { + const body = (await req.json()) as Partial + const contact: Contact = { + id: makeId("c"), + name: (body.name || "").trim(), + email: (body.email || "").trim(), + address: body.address, + taxId: body.taxId, + walletAddress: body.walletAddress, + initials: body.initials ?? body.name?.slice(0, 2).toUpperCase(), + avatarColor: body.avatarColor ?? "#1a1a1a", + } + contacts.push(contact) + return json(contact, 201) + } + + if (path === "/invoices" && method === "GET") { + const status = url.searchParams.get("status") + const list = status + ? invoices.filter((i) => i.status === status) + : invoices + return json(list) + } + + if (path === "/invoices" && method === "POST") { + const body = (await req.json()) as Partial + const inv: Invoice = { + id: makeId("inv"), + number: body.number || `ASM${String(invoices.length + 1).padStart(5, "0")}`, + status: body.status || "draft", + contactId: body.contactId || contacts[0].id, + currency: body.currency || "USD", + issueDate: body.issueDate || new Date().toISOString().slice(0, 10), + dueDate: body.dueDate || new Date().toISOString().slice(0, 10), + lineItems: body.lineItems || [], + note: body.note ?? null, + discount: body.discount ?? 0, + paymentMethod: body.paymentMethod || "USDT ETH", + memo: body.memo ?? null, + paidDate: null, + createdAt: new Date().toISOString(), + } + invoices.push(inv) + return json(inv, 201) + } + + const invMatch = path.match(/^\/invoices\/([^/]+)$/) + if (invMatch) { + const id = invMatch[1] + const idx = invoices.findIndex((i) => i.id === id) + if (idx === -1) return json({ error: "not found" }, 404) + if (method === "PATCH") { + const body = (await req.json()) as Partial + invoices[idx] = { ...invoices[idx], ...body, id } + return json(invoices[idx]) + } + if (method === "DELETE") { + const [removed] = invoices.splice(idx, 1) + return json(removed) + } + if (method === "GET") return json(invoices[idx]) + } + + if (path === "/bills" && method === "GET") { + const status = url.searchParams.get("status") + const list = status ? bills.filter((b) => b.status === status) : bills + return json(list) + } + + if (path === "/payment-methods" && method === "GET") return json(paymentMethods) + + if (path === "/transactions" && method === "GET") return json(transactions) + + return json({ error: "not found", path }, 404) +} + +export const config: Config = { + path: "/api/*", +} diff --git a/apps/acctual/netlify/functions/lib/data.ts b/apps/acctual/netlify/functions/lib/data.ts new file mode 100644 index 0000000..4540b6c --- /dev/null +++ b/apps/acctual/netlify/functions/lib/data.ts @@ -0,0 +1,291 @@ +// In-memory data store for the Acctual clone backend. + +export type InvoiceStatus = "draft" | "sent" | "unpaid" | "overdue" | "paid" | "void" +export type BillStatus = "draft" | "approve" | "ready" | "paid" +export type PaymentMethodType = "crypto" | "fiat" + +export interface LineItem { + id: string + description: string + qty: number + price: number +} + +export interface Contact { + id: string + name: string + email: string + address?: string + taxId?: string + walletAddress?: string + initials?: string + avatarColor?: string +} + +export interface Invoice { + id: string + number: string + status: InvoiceStatus + contactId: string + currency: string + issueDate: string + dueDate: string + lineItems: LineItem[] + note: string | null + discount: number + paymentMethod: string + memo: string | null + paidDate: string | null + createdAt: string +} + +export interface Bill { + id: string + vendor: string + invoiceNumber: string + status: BillStatus + amount: number + currency: string + method: string + methodType: "fiat" | "crypto" + dueDate: string + paidDate: string | null + memo: string + initials: string + avatarColor: string +} + +export interface PaymentMethod { + id: string + name: string + type: PaymentMethodType + network?: string + asset?: string + walletAddress?: string + currency?: string + flexible: boolean +} + +export interface Transaction { + id: string + date: string + to: string + amount: number + currency: string + status: string + reference: string +} + +export const profile = { + name: "Alex Smith", + email: "alexsmith.mobbin+1@gmail.com", + address: "1226 University Dr, Menlo Park, CA, 94025, USA", +} + +export const contacts: Contact[] = [ + { + id: "c1", + name: "john smith", + email: "jsmith.mobbin+1@gmail.com", + address: "75 Ayer Rajah Cres, Singapore, 02, 139953, SGP", + initials: "JS", + avatarColor: "#1a1a1a", + }, + { + id: "c2", + name: "Jane Doe", + email: "jdoe@gmail.com", + walletAddress: "jane d... doe", + initials: "SJ.AI", + avatarColor: "#111111", + }, +] + +export const invoices: Invoice[] = [ + { + id: "inv1", + number: "ASM00001", + status: "unpaid", + contactId: "c1", + currency: "USD", + issueDate: "2025-10-23", + dueDate: "2025-11-05", + lineItems: [ + { id: "li1", description: "Brush Pack #10", qty: 1, price: 2.6 }, + { id: "li2", description: "UI Micro Kit", qty: 1, price: 5.6 }, + ], + note: "Brush Pack #10 — Ink Stroke Mini. UI Micro Kit — component library.", + discount: 2, + paymentMethod: "USDT ETH", + memo: null, + paidDate: null, + createdAt: "2025-10-23T10:00:00.000Z", + }, + { + id: "inv2", + number: "ASM00002", + status: "paid", + contactId: "c1", + currency: "USD", + issueDate: "2025-10-23", + dueDate: "2025-11-05", + lineItems: [ + { id: "li3", description: "Brush Pack #10", qty: 1, price: 2.6 }, + { id: "li4", description: "UI Micro Kit", qty: 1, price: 5.6 }, + ], + note: "Brush Pack #10 — Ink Stroke Mini. UI Micro Kit — component library.", + discount: 2, + paymentMethod: "USDT ETH", + memo: "Paid to MetaMask Wallet", + paidDate: "2025-10-24", + createdAt: "2025-10-23T10:00:00.000Z", + }, + { + id: "inv3", + number: "ASM00003", + status: "overdue", + contactId: "c2", + currency: "USD", + issueDate: "2025-09-15", + dueDate: "2025-10-01", + lineItems: [ + { id: "li5", description: "Brand identity package", qty: 1, price: 10000 }, + { id: "li6", description: "Website redesign", qty: 1, price: 10000 }, + ], + note: null, + discount: 0, + paymentMethod: "USD ACH", + memo: null, + paidDate: null, + createdAt: "2025-09-15T10:00:00.000Z", + }, + { + id: "inv4", + number: "ASM00004", + status: "draft", + contactId: "c1", + currency: "SGD", + issueDate: "2025-10-23", + dueDate: "2025-11-06", + lineItems: [], + note: null, + discount: 0, + paymentMethod: "USDT ETH", + memo: null, + paidDate: null, + createdAt: "2025-10-23T12:00:00.000Z", + }, +] + +export const bills: Bill[] = [ + { + id: "b1", + vendor: "Jane Doe", + invoiceNumber: "TST0001", + status: "draft", + amount: 0.5, + currency: "USD", + method: "USD", + methodType: "fiat", + dueDate: "2025-10-24", + paidDate: null, + memo: "TEST", + initials: "JD", + avatarColor: "#111111", + }, + { + id: "b2", + vendor: "Jane Doe", + invoiceNumber: "B-2025-095", + status: "paid", + amount: 1, + currency: "USD", + method: "USD", + methodType: "fiat", + dueDate: "2025-11-07", + paidDate: "2025-10-24", + memo: "tools expenses", + initials: "JD", + avatarColor: "#111111", + }, + { + id: "b3", + vendor: "John Smith", + invoiceNumber: "", + status: "paid", + amount: 1, + currency: "ETH", + method: "ETH", + methodType: "crypto", + dueDate: "2025-10-27", + paidDate: "2025-10-27", + memo: "Transfer for payment flow", + initials: "JO", + avatarColor: "#7c3aed", + }, + { + id: "b4", + vendor: "Jane Doe", + invoiceNumber: "B-2025-096", + status: "ready", + amount: 9.3, + currency: "USD", + method: "USD", + methodType: "fiat", + dueDate: "2025-11-10", + paidDate: null, + memo: "design stock", + initials: "JD", + avatarColor: "#111111", + }, +] + +export const paymentMethods: PaymentMethod[] = [ + { + id: "pm1", + name: "ASMobbin", + type: "crypto", + network: "Ethereum", + asset: "USDT", + walletAddress: "0xDEAF...fB8B", + flexible: true, + }, + { + id: "pm2", + name: "Alex Smith", + type: "fiat", + currency: "USD", + flexible: true, + }, +] + +export const transactions: Transaction[] = [ + { + id: "t1", + date: "2025-10-27", + to: "John Smith", + amount: 1, + currency: "ETH", + status: "Completed", + reference: "Internal transfer", + }, + { + id: "t2", + date: "2025-10-24", + to: "Jane Doe", + amount: 1, + currency: "USD", + status: "Completed", + reference: "B-2025-095", + }, +] + +let nextId = 100 +export function makeId(prefix: string) { + return `${prefix}${nextId++}` +} + +export function invoiceTotal(inv: Invoice) { + const subtotal = inv.lineItems.reduce((s, i) => s + i.qty * i.price, 0) + return Math.max(0, subtotal - inv.discount) +} diff --git a/apps/acctual/package.json b/apps/acctual/package.json new file mode 100644 index 0000000..decb4e0 --- /dev/null +++ b/apps/acctual/package.json @@ -0,0 +1,58 @@ +{ + "name": "acctual", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "start": "netlify dev", + "build": "tsc -b && vite build", + "lint": "eslint .", + "format": "prettier --write \"**/*.{ts,tsx}\"", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@tailwindcss/vite": "^4", + "@tanstack/react-query": "^5.101.0", + "ajv": "^8.20.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.4.0", + "lucide-react": "^1.17.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.5.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "shadcn": "^4.11.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@eslint/js": "^10", + "@netlify/functions": "^5.3.0", + "@types/node": "^24", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6", + "eslint": "^10", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17", + "netlify-cli": "^26.1.0", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.8.0", + "typescript": "~6", + "typescript-eslint": "^8", + "vite": "^8" + }, + "overrides": { + "ajv-errors": { + "ajv": "^8" + } + } +} diff --git a/apps/acctual/project.json b/apps/acctual/project.json new file mode 100644 index 0000000..ddd2cb4 --- /dev/null +++ b/apps/acctual/project.json @@ -0,0 +1,36 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "acctual", + "projectType": "application", + "root": "apps/acctual", + "sourceRoot": "apps/acctual/src", + "targets": { + "build": { + "executor": "nx:run-commands", + "outputs": [ + "{projectRoot}/dist" + ], + "options": { + "command": "npm run build --workspace=apps/acctual" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "npm run lint --workspace=apps/acctual" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "npm run typecheck --workspace=apps/acctual" + } + }, + "dev": { + "executor": "nx:run-commands", + "options": { + "command": "npm run dev --workspace=apps/acctual" + } + } + } +} diff --git a/apps/acctual/public/vite.svg b/apps/acctual/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/acctual/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/acctual/src/App.tsx b/apps/acctual/src/App.tsx new file mode 100644 index 0000000..ea5b45f --- /dev/null +++ b/apps/acctual/src/App.tsx @@ -0,0 +1,21 @@ +import { Sidebar } from "@/components/sidebar" +import { MainView } from "@/components/main-view" +import { CreateInvoiceDialog } from "@/components/create-invoice-dialog" +import { ViewInvoiceDialog } from "@/components/view-invoice-dialog" +import { AddContactDrawer } from "@/components/add-contact-drawer" + +export function App() { + return ( +
+ +
+ +
+ + + +
+ ) +} + +export default App diff --git a/apps/acctual/src/assets/react.svg b/apps/acctual/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/acctual/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/acctual/src/components/acctual-logo.tsx b/apps/acctual/src/components/acctual-logo.tsx new file mode 100644 index 0000000..fd15eef --- /dev/null +++ b/apps/acctual/src/components/acctual-logo.tsx @@ -0,0 +1,17 @@ +export function AcctualLogo({ className = "" }: { className?: string }) { + return ( +
+ + + + + Acctual +
+ ) +} diff --git a/apps/acctual/src/components/add-contact-drawer.tsx b/apps/acctual/src/components/add-contact-drawer.tsx new file mode 100644 index 0000000..3dc5200 --- /dev/null +++ b/apps/acctual/src/components/add-contact-drawer.tsx @@ -0,0 +1,137 @@ +import { useState } from "react" +import { X } from "lucide-react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { useUIStore } from "@/store/ui-store" +import { useCreateContact } from "@/queries/acctual" +import { cn } from "@/lib/utils" + +export function AddContactDrawer() { + const open = useUIStore((s) => s.addContactOpen) + const setOpen = useUIStore((s) => s.setAddContactOpen) + const create = useCreateContact() + + const [name, setName] = useState("") + const [email, setEmail] = useState("") + const [address, setAddress] = useState("") + const [taxId, setTaxId] = useState("") + + const reset = () => { + setName("") + setEmail("") + setAddress("") + setTaxId("") + } + + const submit = () => { + if (!name.trim() || !email.trim()) return + create.mutate( + { name, email, address: address || undefined, taxId: taxId || undefined }, + { + onSuccess: () => { + toast.success("Contact added") + reset() + setOpen(false) + }, + } + ) + } + + return ( + <> +
setOpen(false)} + /> +
+
+

Add contact

+ +
+ +
+
+ + setName(e.target.value)} + placeholder="Jane Doe" + className="w-full rounded-lg border px-3 py-2 text-sm" + /> +
+
+ + setEmail(e.target.value)} + placeholder="jdoe@gmail.com" + className="w-full rounded-lg border px-3 py-2 text-sm" + /> +
+ +
+
Payment details
+ + +
+ +
+
Billing details
+ + setAddress(e.target.value)} + placeholder="Enter or search for address…" + className="w-full rounded-lg border px-3 py-2 text-sm" + /> +
+
+ + setTaxId(e.target.value)} + placeholder="Enter tax ID…" + className="w-full rounded-lg border px-3 py-2 text-sm" + /> +
+
+ +
+ + +
+
+ + ) +} diff --git a/apps/acctual/src/components/bills-view.tsx b/apps/acctual/src/components/bills-view.tsx new file mode 100644 index 0000000..992b459 --- /dev/null +++ b/apps/acctual/src/components/bills-view.tsx @@ -0,0 +1,155 @@ +import { ArrowLeftRight, Copy, Plus } from "lucide-react" +import { cn } from "@/lib/utils" +import { useUIStore } from "@/store/ui-store" +import { useBills } from "@/queries/acctual" +import { + BILL_TABS, + formatDate, + formatMoney, + type BillStatus, +} from "@/lib/types" +import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" + +export function BillsView() { + const billTab = useUIStore((s) => s.billTab) + const setBillTab = useUIStore((s) => s.setBillTab) + const { data: bills = [], isLoading } = useBills() + + const counts = BILL_TABS.reduce( + (acc, tab) => { + acc[tab.key] = bills.filter((b) => b.status === tab.key).length + return acc + }, + {} as Record + ) + + const filtered = bills.filter((b) => b.status === billTab) + + return ( +
+
+

Bills

+
+ + +
+
+ +
+
+ {BILL_TABS.map((tab) => ( + + ))} +
+
+ Forward invoices to bills@ap.acctual.com + +
+
+ +
+ {isLoading ? ( +

+ Loading… +

+ ) : filtered.length === 0 ? ( +

+ No bills in this tab. +

+ ) : ( + + + + + + + + + + + + + {filtered.map((bill) => ( + + + + + + + + + ))} + +
Vendor / Pay toAmountMethod + {billTab === "paid" ? "Paid date" : "Due date"} + Invoice #Memo
+
+ + + {bill.initials} + + +
+
{bill.vendor}
+ {bill.methodType === "crypto" && ( +
+ Internal Transfer +
+ )} +
+
+
+ {formatMoney(bill.amount, bill.currency)} + +
{bill.method}
+
+ {bill.methodType} +
+
+
+ {formatDate( + billTab === "paid" && bill.paidDate + ? bill.paidDate + : bill.dueDate + )} +
+ {billTab === "draft" && ( +
Overdue 17 hours
+ )} +
+ {bill.invoiceNumber || "—"} + {bill.memo}
+ )} +
+
+ ) +} diff --git a/apps/acctual/src/components/contacts-view.tsx b/apps/acctual/src/components/contacts-view.tsx new file mode 100644 index 0000000..f0c1098 --- /dev/null +++ b/apps/acctual/src/components/contacts-view.tsx @@ -0,0 +1,75 @@ +import { Copy, Plus } from "lucide-react" +import { useUIStore } from "@/store/ui-store" +import { useContacts } from "@/queries/acctual" +import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" + +export function ContactsView() { + const setAddContactOpen = useUIStore((s) => s.setAddContactOpen) + const { data: contacts = [], isLoading } = useContacts() + + return ( +
+
+

Contacts

+ +
+ +
+ {isLoading ? ( +

+ Loading… +

+ ) : ( + + + + + + + + + + {contacts.map((contact) => ( + + + + + + ))} + +
NameEmailPayment details
+
+ + + {contact.initials ?? + contact.name.slice(0, 2).toUpperCase()} + + + {contact.name} +
+
{contact.email} + {contact.walletAddress ? ( + + {contact.walletAddress} + + + ) : ( + + )} +
+ )} +
+
+ ) +} diff --git a/apps/acctual/src/components/create-invoice-dialog.tsx b/apps/acctual/src/components/create-invoice-dialog.tsx new file mode 100644 index 0000000..4e01b55 --- /dev/null +++ b/apps/acctual/src/components/create-invoice-dialog.tsx @@ -0,0 +1,330 @@ +import { useState } from "react" +import { Plus, X } from "lucide-react" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { InvoicePreview } from "@/components/invoice-preview" +import { useUIStore } from "@/store/ui-store" +import { + useContacts, + useCreateInvoice, + useProfile, +} from "@/queries/acctual" +import type { Invoice, LineItem } from "@/lib/types" + +const STEPS = ["Customer", "Items", "Terms", "Payment"] as const + +export function CreateInvoiceDialog() { + const open = useUIStore((s) => s.createInvoiceOpen) + const setOpen = useUIStore((s) => s.setCreateInvoiceOpen) + const { data: contacts = [] } = useContacts() + const { data: profile } = useProfile() + const create = useCreateInvoice() + + const [step, setStep] = useState(0) + const [contactId, setContactId] = useState(contacts[0]?.id ?? "") + const [lineItems, setLineItems] = useState([ + { id: "new1", description: "", qty: 1, price: 0 }, + ]) + const [number, setNumber] = useState("ASM00005") + const [issueDate, setIssueDate] = useState("2025-10-23") + const [dueDate, setDueDate] = useState("2025-11-06") + const [discount, setDiscount] = useState(0) + const [paymentMethod, setPaymentMethod] = useState("USDT ETH") + + const contact = contacts.find((c) => c.id === contactId) + + const preview: Invoice = { + id: "preview", + number, + status: "draft", + contactId, + currency: "USD", + issueDate, + dueDate, + lineItems: lineItems.filter((i) => i.description), + note: null, + discount, + paymentMethod, + memo: null, + paidDate: null, + createdAt: new Date().toISOString(), + } + + const reset = () => { + setStep(0) + setContactId(contacts[0]?.id ?? "") + setLineItems([{ id: "new1", description: "", qty: 1, price: 0 }]) + setDiscount(0) + } + + const submit = () => { + create.mutate( + { + number, + contactId, + issueDate, + dueDate, + lineItems: lineItems.filter((i) => i.description), + discount, + paymentMethod, + status: "draft", + }, + { + onSuccess: () => { + toast.success("Invoice saved as draft") + reset() + setOpen(false) + }, + } + ) + } + + return ( + { + setOpen(v) + if (!v) reset() + }} + > + +
+
+ + Create invoice + + +
+ +
+ {STEPS.map((s, i) => ( + + {s} + {i < STEPS.length - 1 && " · "} + + ))} +
+ + {step === 0 && ( +
+

+ Who's this invoice for? +

+
+ + +

+ We'll fill in their details automatically if we've + seen them before +

+
+
+ )} + + {step === 1 && ( +
+

Line items

+ {lineItems.map((item, idx) => ( +
+ { + const next = [...lineItems] + next[idx] = { ...item, description: e.target.value } + setLineItems(next) + }} + className="rounded-lg border px-3 py-2 text-sm" + /> + { + const next = [...lineItems] + next[idx] = { ...item, qty: Number(e.target.value) } + setLineItems(next) + }} + className="rounded-lg border px-2 py-2 text-sm" + /> + { + const next = [...lineItems] + next[idx] = { ...item, price: Number(e.target.value) } + setLineItems(next) + }} + className="rounded-lg border px-2 py-2 text-sm" + /> +
+ ))} + +
+ + setDiscount(Number(e.target.value))} + className="w-full rounded-lg border px-3 py-2 text-sm" + /> +
+
+ )} + + {step === 2 && ( +
+

Terms

+
+ + setNumber(e.target.value)} + className="w-full rounded-lg border px-3 py-2 text-sm" + /> +
+
+
+ + setIssueDate(e.target.value)} + className="w-full rounded-lg border px-3 py-2 text-sm" + /> +
+
+ + setDueDate(e.target.value)} + className="w-full rounded-lg border px-3 py-2 text-sm" + /> +
+
+
+ )} + + {step === 3 && ( +
+

Payment details

+
+ + +
+ +
+ )} + +
+ +
+ {step > 0 && ( + + )} + {step < STEPS.length - 1 ? ( + + ) : ( + + )} +
+
+
+ +
+ +
+
+
+ ) +} diff --git a/apps/acctual/src/components/invoice-preview.tsx b/apps/acctual/src/components/invoice-preview.tsx new file mode 100644 index 0000000..52ac0a0 --- /dev/null +++ b/apps/acctual/src/components/invoice-preview.tsx @@ -0,0 +1,166 @@ +import type { Contact, Invoice, Profile } from "@/lib/types" +import { + formatDate, + formatMoney, + invoiceSubtotal, + invoiceTotal, +} from "@/lib/types" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" + +export function InvoicePreview({ + invoice, + contact, + profile, + highlightTo, +}: { + invoice: Invoice + contact?: Contact + profile?: Profile + highlightTo?: boolean +}) { + const subtotal = invoiceSubtotal(invoice) + const total = invoiceTotal(invoice) + + return ( +
+
+
+
+
Invoice no
+
+ {invoice.number} +
+
+
+
Issued
+
+ {formatDate(invoice.issueDate)} +
+
+
+
Due date
+
+ {formatDate(invoice.dueDate)} +
+
+
+ +
+
+
+ From +
+
+ + + {profile?.name?.slice(0, 1) ?? "A"} + + +
+
{profile?.name}
+
{profile?.email}
+
{profile?.address}
+
+
+
+
+
+ To +
+ {contact ? ( +
+ + + {contact.initials ?? contact.name.slice(0, 2).toUpperCase()} + + +
+
{contact.name}
+
{contact.email}
+ {contact.address && ( +
{contact.address}
+ )} +
+
+ ) : ( +
+ )} +
+
+ + + + + + + + + + + + {invoice.lineItems.length === 0 ? ( + + + + ) : ( + invoice.lineItems.map((item) => ( + + + + + + + )) + )} + +
DescriptionQtyPriceAmount
+ No line items yet +
{item.description}{item.qty} + {formatMoney(item.price, invoice.currency)} + + {formatMoney(item.qty * item.price, invoice.currency)} +
+ +
+ {invoice.note && ( +
+
Note
+ {invoice.note} +
+ )} +
+
+ Subtotal + {formatMoney(subtotal, invoice.currency)} +
+ {invoice.discount > 0 && ( +
+ Discount + -{formatMoney(invoice.discount, invoice.currency)} +
+ )} +
+ Total + {formatMoney(total, invoice.currency)} +
+
+
+ +
+ Powered by Acctual + Choose how you'd like to pay → +
+
+
+ ) +} diff --git a/apps/acctual/src/components/invoices-view.tsx b/apps/acctual/src/components/invoices-view.tsx new file mode 100644 index 0000000..a55b466 --- /dev/null +++ b/apps/acctual/src/components/invoices-view.tsx @@ -0,0 +1,183 @@ +import { Plus } from "lucide-react" +import { cn } from "@/lib/utils" +import { useUIStore } from "@/store/ui-store" +import { useContacts, useInvoices } from "@/queries/acctual" +import { + INVOICE_TABS, + formatDate, + formatMoney, + invoiceTotal, + type Invoice, + type InvoiceStatus, +} from "@/lib/types" +import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" + +function StatusBadge({ status }: { status: InvoiceStatus }) { + const styles: Record = { + draft: "bg-neutral-100 text-neutral-600", + unpaid: "bg-amber-50 text-amber-700", + overdue: "bg-red-50 text-red-600", + paid: "bg-emerald-50 text-emerald-700", + sent: "bg-blue-50 text-blue-700", + void: "bg-neutral-100 text-neutral-500", + } + return ( + + {status} + + ) +} + +export function InvoicesView() { + const invoiceTab = useUIStore((s) => s.invoiceTab) + const setInvoiceTab = useUIStore((s) => s.setInvoiceTab) + const setCreateInvoiceOpen = useUIStore((s) => s.setCreateInvoiceOpen) + const selectInvoice = useUIStore((s) => s.selectInvoice) + const setViewInvoiceOpen = useUIStore((s) => s.setViewInvoiceOpen) + + const { data: invoices = [], isLoading } = useInvoices() + const { data: contacts = [] } = useContacts() + + const counts = INVOICE_TABS.reduce( + (acc, tab) => { + acc[tab.key] = invoices.filter((i) => i.status === tab.key).length + return acc + }, + {} as Record + ) + + const overdueTotal = invoices + .filter((i) => i.status === "overdue") + .reduce((s, i) => s + invoiceTotal(i), 0) + + const filtered = + invoiceTab === "all" + ? invoices + : invoices.filter((i) => i.status === invoiceTab) + + const openInvoice = (inv: Invoice) => { + selectInvoice(inv.id) + setViewInvoiceOpen(true) + } + + return ( +
+
+

Invoices

+ +
+ + {overdueTotal > 0 && ( +
+ {counts.overdue ?? 0} overdue · {formatMoney(overdueTotal, "USD")} +
+ )} + +
+ {INVOICE_TABS.map((tab) => ( + + ))} +
+ +
+ {isLoading ? ( +

+ Loading… +

+ ) : filtered.length === 0 ? ( +

+ No invoices in this tab. +

+ ) : ( + + + + + + + + + + + + + {filtered.map((inv) => { + const contact = contacts.find((c) => c.id === inv.contactId) + return ( + openInvoice(inv)} + className="cursor-pointer border-b transition-colors hover:bg-muted/40" + > + + + + + + + + ) + })} + +
CustomerInvoice #AmountDue dateStatusMethod
+
+ + + {contact?.initials ?? + contact?.name.slice(0, 2).toUpperCase()} + + + {contact?.name ?? "—"} +
+
{inv.number} + {formatMoney(invoiceTotal(inv), inv.currency)} + + {formatDate(inv.dueDate)} + + + + {inv.paymentMethod} +
+ )} +
+
+ ) +} diff --git a/apps/acctual/src/components/main-view.tsx b/apps/acctual/src/components/main-view.tsx new file mode 100644 index 0000000..26e1523 --- /dev/null +++ b/apps/acctual/src/components/main-view.tsx @@ -0,0 +1,20 @@ +import { useUIStore } from "@/store/ui-store" +import { InvoicesView } from "@/components/invoices-view" +import { PaymentsView } from "@/components/payments-view" +import { BillsView } from "@/components/bills-view" +import { ContactsView } from "@/components/contacts-view" + +export function MainView() { + const view = useUIStore((s) => s.view) + + switch (view) { + case "payments": + return + case "bills": + return + case "contacts": + return + default: + return + } +} diff --git a/apps/acctual/src/components/payments-view.tsx b/apps/acctual/src/components/payments-view.tsx new file mode 100644 index 0000000..83aa951 --- /dev/null +++ b/apps/acctual/src/components/payments-view.tsx @@ -0,0 +1,123 @@ +import { ChevronDown, Fingerprint, UserCheck, Wallet } from "lucide-react" +import { cn } from "@/lib/utils" +import { useUIStore } from "@/store/ui-store" +import { + usePaymentMethods, + useTransactions, +} from "@/queries/acctual" +import { formatDate, formatMoney } from "@/lib/types" +import { Button } from "@/components/ui/button" + +export function PaymentsView() { + const paymentsTab = useUIStore((s) => s.paymentsTab) + const setPaymentsTab = useUIStore((s) => s.setPaymentsTab) + const { data: methods = [] } = usePaymentMethods() + const { data: transactions = [] } = useTransactions() + + return ( +
+
+

Payments

+ +
+ +
+ {(["receive", "transfer"] as const).map((tab) => ( + + ))} +
+ +
+
+

+ Let's set up how you want to get paid +

+

+ Clients can pay however they want — you always get what works for you +

+
+ +
+
+
+ PENDING +
+
+ 🇺🇸 + 🇪🇺 + +12 + + USDT +
+

+ {methods.length} ways to USDT ( + {methods[0]?.walletAddress ?? "0xDEAF...fB8B"}) +

+
+ +
+ +
Choose where to get paid
+

+ Wallet or bank — USDT, USD, EUR, and more +

+
+ +
+ +
Verify your identity
+

+ A quick one-time check to unlock flexible payments +

+
+ +
+ +
Get approved
+

+ Usually under 24 hours. We'll email you when it's done. +

+
+
+ + + + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + + ))} + +
DateToAmountStatusReference
{formatDate(tx.date)}{tx.to} + {formatMoney(tx.amount, tx.currency)} + {tx.status}{tx.reference}
+
+
+ ) +} diff --git a/apps/acctual/src/components/sidebar.tsx b/apps/acctual/src/components/sidebar.tsx new file mode 100644 index 0000000..a648a6f --- /dev/null +++ b/apps/acctual/src/components/sidebar.tsx @@ -0,0 +1,104 @@ +import { + BookOpen, + ChevronDown, + CreditCard, + FileText, + HelpCircle, + Receipt, + Settings, + Shield, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { AcctualLogo } from "@/components/acctual-logo" +import { useUIStore } from "@/store/ui-store" +import { useProfile } from "@/queries/acctual" +import type { ViewId } from "@/lib/types" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" + +const NAV: { id: ViewId; label: string; icon: React.ReactNode }[] = [ + { id: "invoices", label: "Invoices", icon: }, + { id: "payments", label: "Payments", icon: }, + { id: "bills", label: "Bills", icon: }, + { id: "contacts", label: "Contacts", icon: }, +] + +function NavRow({ + active, + icon, + label, + onClick, +}: { + active?: boolean + icon: React.ReactNode + label: string + onClick: () => void +}) { + return ( + + ) +} + +export function Sidebar() { + const view = useUIStore((s) => s.view) + const setView = useUIStore((s) => s.setView) + const { data: profile } = useProfile() + + return ( + + ) +} diff --git a/apps/acctual/src/components/theme-provider.tsx b/apps/acctual/src/components/theme-provider.tsx new file mode 100644 index 0000000..1349a0c --- /dev/null +++ b/apps/acctual/src/components/theme-provider.tsx @@ -0,0 +1,230 @@ +/* eslint-disable react-refresh/only-export-components */ +import * as React from "react" + +type Theme = "dark" | "light" | "system" +type ResolvedTheme = "dark" | "light" + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string + disableTransitionOnChange?: boolean +} + +type ThemeProviderState = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)" +const THEME_VALUES: Theme[] = ["dark", "light", "system"] + +const ThemeProviderContext = React.createContext< + ThemeProviderState | undefined +>(undefined) + +function isTheme(value: string | null): value is Theme { + if (value === null) { + return false + } + + return THEME_VALUES.includes(value as Theme) +} + +function getSystemTheme(): ResolvedTheme { + if (window.matchMedia(COLOR_SCHEME_QUERY).matches) { + return "dark" + } + + return "light" +} + +function disableTransitionsTemporarily() { + const style = document.createElement("style") + style.appendChild( + document.createTextNode( + "*,*::before,*::after{-webkit-transition:none!important;transition:none!important}" + ) + ) + document.head.appendChild(style) + + return () => { + window.getComputedStyle(document.body) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + style.remove() + }) + }) + } +} + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) { + return false + } + + if (target.isContentEditable) { + return true + } + + const editableParent = target.closest( + "input, textarea, select, [contenteditable='true']" + ) + if (editableParent) { + return true + } + + return false +} + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "theme", + disableTransitionOnChange = true, + ...props +}: ThemeProviderProps) { + const [theme, setThemeState] = React.useState(() => { + const storedTheme = localStorage.getItem(storageKey) + if (isTheme(storedTheme)) { + return storedTheme + } + + return defaultTheme + }) + + const setTheme = React.useCallback( + (nextTheme: Theme) => { + localStorage.setItem(storageKey, nextTheme) + setThemeState(nextTheme) + }, + [storageKey] + ) + + const applyTheme = React.useCallback( + (nextTheme: Theme) => { + const root = document.documentElement + const resolvedTheme = + nextTheme === "system" ? getSystemTheme() : nextTheme + const restoreTransitions = disableTransitionOnChange + ? disableTransitionsTemporarily() + : null + + root.classList.remove("light", "dark") + root.classList.add(resolvedTheme) + + if (restoreTransitions) { + restoreTransitions() + } + }, + [disableTransitionOnChange] + ) + + React.useEffect(() => { + applyTheme(theme) + + if (theme !== "system") { + return undefined + } + + const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY) + const handleChange = () => { + applyTheme("system") + } + + mediaQuery.addEventListener("change", handleChange) + + return () => { + mediaQuery.removeEventListener("change", handleChange) + } + }, [theme, applyTheme]) + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.repeat) { + return + } + + if (event.metaKey || event.ctrlKey || event.altKey) { + return + } + + if (isEditableTarget(event.target)) { + return + } + + if (event.key.toLowerCase() !== "d") { + return + } + + setThemeState((currentTheme) => { + const nextTheme = + currentTheme === "dark" + ? "light" + : currentTheme === "light" + ? "dark" + : getSystemTheme() === "dark" + ? "light" + : "dark" + + localStorage.setItem(storageKey, nextTheme) + return nextTheme + }) + } + + window.addEventListener("keydown", handleKeyDown) + + return () => { + window.removeEventListener("keydown", handleKeyDown) + } + }, [storageKey]) + + React.useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.storageArea !== localStorage) { + return + } + + if (event.key !== storageKey) { + return + } + + if (isTheme(event.newValue)) { + setThemeState(event.newValue) + return + } + + setThemeState(defaultTheme) + } + + window.addEventListener("storage", handleStorageChange) + + return () => { + window.removeEventListener("storage", handleStorageChange) + } + }, [defaultTheme, storageKey]) + + const value = React.useMemo( + () => ({ + theme, + setTheme, + }), + [theme, setTheme] + ) + + return ( + + {children} + + ) +} + +export const useTheme = () => { + const context = React.useContext(ThemeProviderContext) + + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider") + } + + return context +} diff --git a/apps/acctual/src/components/ui/avatar.tsx b/apps/acctual/src/components/ui/avatar.tsx new file mode 100644 index 0000000..99f3ed2 --- /dev/null +++ b/apps/acctual/src/components/ui/avatar.tsx @@ -0,0 +1,110 @@ +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/apps/acctual/src/components/ui/badge.tsx b/apps/acctual/src/components/ui/badge.tsx new file mode 100644 index 0000000..cacff11 --- /dev/null +++ b/apps/acctual/src/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/apps/acctual/src/components/ui/button.tsx b/apps/acctual/src/components/ui/button.tsx new file mode 100644 index 0000000..75b8c3d --- /dev/null +++ b/apps/acctual/src/components/ui/button.tsx @@ -0,0 +1,67 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/apps/acctual/src/components/ui/checkbox.tsx b/apps/acctual/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..cec7a77 --- /dev/null +++ b/apps/acctual/src/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon } from "lucide-react" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/apps/acctual/src/components/ui/dialog.tsx b/apps/acctual/src/components/ui/dialog.tsx new file mode 100644 index 0000000..527af74 --- /dev/null +++ b/apps/acctual/src/components/ui/dialog.tsx @@ -0,0 +1,166 @@ +import * as React from "react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/apps/acctual/src/components/ui/dropdown-menu.tsx b/apps/acctual/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..c263ad5 --- /dev/null +++ b/apps/acctual/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,269 @@ +"use client" + +import * as React from "react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon, ChevronRightIcon } from "lucide-react" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/apps/acctual/src/components/ui/input.tsx b/apps/acctual/src/components/ui/input.tsx new file mode 100644 index 0000000..d763cd9 --- /dev/null +++ b/apps/acctual/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/apps/acctual/src/components/ui/label.tsx b/apps/acctual/src/components/ui/label.tsx new file mode 100644 index 0000000..f752f82 --- /dev/null +++ b/apps/acctual/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import { Label as LabelPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/apps/acctual/src/components/ui/popover.tsx b/apps/acctual/src/components/ui/popover.tsx new file mode 100644 index 0000000..0ad11df --- /dev/null +++ b/apps/acctual/src/components/ui/popover.tsx @@ -0,0 +1,87 @@ +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} diff --git a/apps/acctual/src/components/ui/scroll-area.tsx b/apps/acctual/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..f49b0a8 --- /dev/null +++ b/apps/acctual/src/components/ui/scroll-area.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/apps/acctual/src/components/ui/select.tsx b/apps/acctual/src/components/ui/select.tsx new file mode 100644 index 0000000..f09dfb4 --- /dev/null +++ b/apps/acctual/src/components/ui/select.tsx @@ -0,0 +1,192 @@ +"use client" + +import * as React from "react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/apps/acctual/src/components/ui/separator.tsx b/apps/acctual/src/components/ui/separator.tsx new file mode 100644 index 0000000..d457090 --- /dev/null +++ b/apps/acctual/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/apps/acctual/src/components/ui/sonner.tsx b/apps/acctual/src/components/ui/sonner.tsx new file mode 100644 index 0000000..9280ee5 --- /dev/null +++ b/apps/acctual/src/components/ui/sonner.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, type ToasterProps } from "sonner" +import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ), + info: ( + + ), + warning: ( + + ), + error: ( + + ), + loading: ( + + ), + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/apps/acctual/src/components/ui/textarea.tsx b/apps/acctual/src/components/ui/textarea.tsx new file mode 100644 index 0000000..04d27f7 --- /dev/null +++ b/apps/acctual/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( + -->

+ +
+ +
+ + + +
+ +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    + + + +
    +
    +
    +

    + Provide feedback +

    + +
    +
    + +
    +
    + +
    + +
    +

    We read every piece of feedback, and take your input very seriously.

    + + + +
    +
    + +
    + + + + + +
    +
    +
    +

    + Saved searches +

    +

    Use saved searches to filter your results more quickly

    +
    +
    + +
    +
    + +
    + +
    + + + +
    +
    +
    + +
    +
    + +
    +
    +
    + + + + + + + + +
    +
    + + + + + + + +
    + + + + + + + + +
    + + + + + +
    + + + + + + + + + +
    +
    + + + + + + + + + + + + +

    The future of building happens together

    Tools and trends evolve, but collaboration endures. With GitHub, developers, agents, and code come together on one platform.

    Try GitHub Copilot

    GitHub features

    A demonstration animation of a code editor using GitHub Copilot Chat, where the user requests GitHub Copilot to refactor duplicated logic and extract it into a reusable function for a given code snippet.

    Write, test, and fix code quickly with GitHub Copilot, from simple boilerplate to complex features.

    GitHub customers

    American AirlinesDuolingoErnst and YoungFordInfoSysMercado LibreMercedes-BenzShopifyPhilipsSociété GénéraleSpotifyVodafone

    Accelerate your entire workflow

    From your first line of code to final deployment, GitHub provides AI and automation tools to help you build and ship better software faster.

    A Copilot chat window with the 'Ask' mode enabled. The user switches from 'Ask' mode to 'Agent' mode from a dropdown menu, then sends the prompt 'Update the website to allow searching for running races by name.' Copilot analyzes the codebase, then explains the required edits for three files before generating them. Copilot then confirms completion and summarizes the implemented changes for the new functionality allowing users to search races by name and view paginated, filtered results.

    Your AI partner everywhere. Copilot is ready to work with you at each step of the software development lifecycle.

    Duolingo boosts developer speed by 25% with GitHub Copilot

    Read customer story

    2025 Gartner® Magic Quadrant™ for AI Code Assistants

    Read industry report

    Ship faster with secure, reliable CI/CD.

    Explore GitHub Actions

    Built-in application security where found means fixed

    Use AI to find and fix vulnerabilities so your team can ship more secure software faster.

    Apply fixes in seconds. Spend less time debugging and more time building features with Copilot Autofix.

    Copilot Autofix identifies vulnerable code and provides an explanation, together with a secure code suggestion to remediate the vulnerability.

    Security debt, solved. Leverage security campaigns and Copilot Autofix to reduce application vulnerabilities.

    Learn about GitHub Code Security
    A security campaign screen displays the campaign’s progress bar with 97% completed of 701 alerts. A total of 23 alerts are left with 13 in progress, and the campaign started 20 days ago. The status below shows that there are 7 days left in the campaign with a due date of November 15, 2024.

    Dependencies you can depend on. Update vulnerable dependencies with supported fixes for breaking changes.

    Learn about Dependabot
    List of dependencies defined in a requirements .txt file.

    Your secrets, your business. Detect, prevent, and remediate leaked secrets across your organization.

    Learn about GitHub Secret Protection
    GitHub push protection confirms and displays an active secret, and blocks the push.

    70% MTTR reduction with Copilot Autofix

    8.3M secret leaks stopped in the past 12 months with push protection

    Work together, achieve more

    From planning and discussion to code review, GitHub keeps your team’s conversation and context next to your code.

    A project management dashboard showing tasks for the ‘OctoArcade Invaders’ project, with tasks grouped under project phase categories like ‘Prototype,’ ‘Beta,’ and ‘Launch’ in a table layout. One of the columns displays sub-issue progress bars with percentages for each issue.

    Plan with clarity. Organize everything from high-level roadmaps to everyday tasks.

    It helps us onboard new software engineers and get them productive right away. We have all our source code, issues, and pull requests in one place... GitHub is a complete platform that frees us from menial tasks and enables us to do our best work.
    Fabian FaulhaberApplication manager at Mercedes-Benz

    Create issues and manage projects with tools that adapt to your code.

    Explore GitHub Issues

    Millions of developers and businesses call GitHub home

    Whether you’re scaling your development process or just learning how to code, GitHub is where you belong. Join the world’s most widely adopted developer platform to build the technologies that shape what’s next.

    +
    + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + diff --git a/apps/blamy-notes/netlify.toml b/apps/blamy-notes/netlify.toml new file mode 100644 index 0000000..904c806 --- /dev/null +++ b/apps/blamy-notes/netlify.toml @@ -0,0 +1,10 @@ +[build] + command = "npm run build" + publish = "dist" + functions = "netlify/functions" + +[dev] + command = "npm run dev" + targetPort = 5173 + port = 8888 + framework = "#custom" diff --git a/apps/blamy-notes/netlify/functions/api.mts b/apps/blamy-notes/netlify/functions/api.mts new file mode 100644 index 0000000..788c4b2 --- /dev/null +++ b/apps/blamy-notes/netlify/functions/api.mts @@ -0,0 +1,325 @@ +import type { Config, Context } from "@netlify/functions" +import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose" + +import { + commitFile, + getFile, + listMarkdownTree, + listUserRepos, + openPullRequest, + userProfile, +} from "./lib/github.ts" +import { GithubNotConnectedError, githubTokenForUser } from "./lib/auth0-vault.ts" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || "webreplay.us.auth0.com" +const AUTH0_AUDIENCE = process.env.AUTH0_AUDIENCE +const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID +const AUTH0_CLIENT_SECRET = process.env.AUTH0_CLIENT_SECRET +const COOKIE = "auth_token" +const REFRESH_COOKIE = "auth_refresh" +// AUTH_BYPASS_JWT: when set, auth is bypassed — every request is treated as +// authenticated, with `sub` taken from this JWT. For local testing/scripting. +const AUTH_BYPASS_JWT = process.env.AUTH_BYPASS_JWT +// AUTH_BYPASS_REFRESH_TOKEN: an Auth0 refresh token used for the GitHub +// Token Vault exchange when no session refresh cookie is present. +const AUTH_BYPASS_REFRESH_TOKEN = process.env.AUTH_BYPASS_REFRESH_TOKEN + +const jwks = createRemoteJWKSet( + new URL(`https://${AUTH0_DOMAIN}/.well-known/jwks.json`) +) + +function cookieValue(req: Request, name: string): string | null { + const cookies = req.headers.get("cookie") || "" + const match = cookies.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)) + return match ? match[1] : null +} + +function bearerOrCookieToken(req: Request): string | null { + const header = req.headers.get("authorization") || "" + if (header.startsWith("Bearer ")) return header.slice(7) + return cookieValue(req, COOKIE) +} + +async function verifyToken(token: string) { + return jwtVerify(token, jwks, { + issuer: `https://${AUTH0_DOMAIN}/`, + ...(AUTH0_AUDIENCE ? { audience: AUTH0_AUDIENCE } : {}), + }) +} + +const setCookie = (name: string, value: string, maxAge: number) => + `${name}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}` + +// Auth0 Regular Web App flow: the SPA lands back on the site root with +// ?code=... and forwards it here; the secret-bearing exchange stays +// server-side. The session is two HttpOnly cookies: the access token (JWT) +// and the refresh token, which Token Vault exchanges for per-user GitHub +// access tokens. +async function handleAuth(req: Request, path: string): Promise { + const sub = path.replace(/^\/auth/, "") || "/" + + if (sub === "/exchange" && req.method === "POST") { + if (!AUTH0_CLIENT_ID || !AUTH0_CLIENT_SECRET) { + return json({ error: "auth not configured" }, 500) + } + const { code, redirect_uri } = (await req.json()) as { + code?: string + redirect_uri?: string + } + if (!code || !redirect_uri) { + return json({ error: "code and redirect_uri required" }, 400) + } + const res = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: AUTH0_CLIENT_ID, + client_secret: AUTH0_CLIENT_SECRET, + code, + redirect_uri, + }), + }) + if (!res.ok) { + return json({ error: "token exchange failed", detail: await res.text() }, 401) + } + const { access_token, refresh_token, expires_in } = (await res.json()) as { + access_token: string + refresh_token?: string + expires_in: number + } + const headers = new Headers({ "content-type": "application/json" }) + headers.append("set-cookie", setCookie(COOKIE, access_token, expires_in ?? 86400)) + if (refresh_token) { + headers.append("set-cookie", setCookie(REFRESH_COOKIE, refresh_token, 30 * 86400)) + } + return new Response(JSON.stringify({ ok: true, offline: !!refresh_token }), { + status: 200, + headers, + }) + } + + if (sub === "/me" && req.method === "GET") { + const session = await ensureSession(req) + if (session.failed || !session.sub) return json({ error: "not authenticated" }, 401) + const res = json({ sub: session.sub }) + for (const c of session.cookies) res.headers.append("set-cookie", c) + return res + } + + if (sub === "/logout" && req.method === "POST") { + const headers = new Headers({ "content-type": "application/json" }) + headers.append("set-cookie", setCookie(COOKIE, "", 0)) + headers.append("set-cookie", setCookie(REFRESH_COOKIE, "", 0)) + return new Response(JSON.stringify({ ok: true }), { status: 200, headers }) + } + + return json({ error: "not found", path }, 404) +} + +interface Session { + failed?: Response + /** Set-Cookie headers to append to the response (after a silent refresh). */ + cookies: string[] + sub?: string +} + +// When a refresh succeeds with rotation, the request still carries the old +// refresh cookie — later lookups in the same request must see the new one. +const refreshedTokens = new WeakMap() + +/** + * Validates the session, silently refreshing the access token with the + * refresh-token grant when it is missing or expired. + */ +async function ensureSession(req: Request): Promise { + // When a bypass JWT is configured, the session is always authenticated. + // The token need not be presented (the SPA uses cookies); we derive `sub` + // from the bypass JWT itself, or from a matching Bearer token if sent. + if (AUTH_BYPASS_JWT) { + let sub = "auth-bypass" + try { + sub = String(decodeJwt(AUTH_BYPASS_JWT).sub ?? sub) + } catch { + /* not a decodable JWT — keep the placeholder sub */ + } + return { cookies: [], sub } + } + + const token = bearerOrCookieToken(req) + if (token) { + try { + const { payload } = await verifyToken(token) + return { cookies: [], sub: String(payload.sub) } + } catch { + /* expired/invalid — fall through to refresh */ + } + } + + const refresh = cookieValue(req, REFRESH_COOKIE) + if (refresh && AUTH0_CLIENT_ID && AUTH0_CLIENT_SECRET) { + const res = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: AUTH0_CLIENT_ID, + client_secret: AUTH0_CLIENT_SECRET, + refresh_token: refresh, + }), + }) + if (res.ok) { + const body = (await res.json()) as { + access_token: string + refresh_token?: string + expires_in?: number + } + try { + const { payload } = await verifyToken(body.access_token) + const cookies = [setCookie(COOKIE, body.access_token, body.expires_in ?? 86400)] + if (body.refresh_token) { + cookies.push(setCookie(REFRESH_COOKIE, body.refresh_token, 30 * 86400)) + refreshedTokens.set(req, body.refresh_token) + } + return { cookies, sub: String(payload.sub) } + } catch { + /* refreshed token failed verification — treat as unauthenticated */ + } + } + } + + return { failed: json({ error: "missing bearer token" }, 401), cookies: [] } +} + +/** GitHub access token for the logged-in user, via Auth0 Token Vault. */ +async function githubToken(req: Request): Promise { + const refresh = + AUTH_BYPASS_REFRESH_TOKEN ?? + refreshedTokens.get(req) ?? + cookieValue(req, REFRESH_COOKIE) + if (!refresh) { + throw new GithubNotConnectedError( + "no refresh token in session — log in again (offline_access)" + ) + } + return githubTokenForUser(refresh) +} + +function githubError(e: unknown): Response { + if (e instanceof GithubNotConnectedError) { + return json({ error: "github_not_connected", detail: e.detail }, 428) + } + return json({ error: String(e) }, 502) +} + +async function handleGithub(req: Request, path: string): Promise { + const sub = path.replace(/^\/github/, "") || "/" + + // GitHub posts push/PR events here (configured on the GitHub App). + if (sub === "/webhook" && req.method === "POST") { + const event = req.headers.get("x-github-event") ?? "unknown" + console.log(`github webhook: ${event}`) + return json({ ok: true }) + } + + // Legacy GitHub App install/OAuth callback — repo access is user-scoped via + // Token Vault now, so this just drops the user back into the app. + if (sub === "/callback" && req.method === "GET") { + return new Response(null, { status: 302, headers: { location: "/" } }) + } + + // GET /github/profile — the user and their orgs, for the owner switcher + if (sub === "/profile" && req.method === "GET") { + try { + const token = await githubToken(req) + return json(await userProfile(token)) + } catch (e) { + return githubError(e) + } + } + + // GET /github/repos — repos the logged-in GitHub user can access + if (sub === "/repos" && req.method === "GET") { + try { + const token = await githubToken(req) + return json(await listUserRepos(token)) + } catch (e) { + return githubError(e) + } + } + + // /github/repos/:owner/:repo/(tree|file|save) + const repoMatch = sub.match(/^\/repos\/([^/]+)\/([^/]+)\/(tree|file|save)$/) + if (repoMatch) { + const fullName = `${repoMatch[1]}/${repoMatch[2]}` + const action = repoMatch[3] + try { + const token = await githubToken(req) + if (action === "tree" && req.method === "GET") { + return json(await listMarkdownTree(token, fullName)) + } + if (action === "file" && req.method === "GET") { + const url = new URL(req.url) + const filePath = url.searchParams.get("path") + if (!filePath) return json({ error: "path required" }, 400) + return json(await getFile(token, fullName, filePath)) + } + if (action === "save" && req.method === "POST") { + const body = (await req.json()) as { + path?: string + content?: string + message?: string + mode?: "main" | "pr" + sha?: string + } + if (!body.path || typeof body.content !== "string") { + return json({ error: "path and content required" }, 400) + } + const message = body.message || `docs: update ${body.path}` + if (body.mode === "pr") { + return json( + await openPullRequest(token, fullName, body.path, body.content, message, body.sha) + ) + } + return json(await commitFile(token, fullName, body.path, body.content, message, body.sha)) + } + } catch (e) { + return githubError(e) + } + } + + return json({ error: "not found", path }, 404) +} + +export default async (req: Request, _context: Context) => { + const url = new URL(req.url) + const path = url.pathname.replace(/^\/api/, "") || "/" + + // Auth endpoints, the GitHub webhook, and the install callback handle + // their own authentication. + if (path.startsWith("/auth")) return handleAuth(req, path) + if (path === "/github/webhook" || path === "/github/callback") { + return handleGithub(req, path) + } + + const session = await ensureSession(req) + if (session.failed) return session.failed + + const res = path.startsWith("/github") + ? await handleGithub(req, path) + : json({ error: "not found", path }, 404) + + // Propagate any silently-refreshed session cookies. + for (const c of session.cookies) res.headers.append("set-cookie", c) + return res +} + +export const config: Config = { + path: "/api/*", +} diff --git a/apps/blamy-notes/netlify/functions/lib/auth0-vault.ts b/apps/blamy-notes/netlify/functions/lib/auth0-vault.ts new file mode 100644 index 0000000..f46b066 --- /dev/null +++ b/apps/blamy-notes/netlify/functions/lib/auth0-vault.ts @@ -0,0 +1,46 @@ +// Auth0 Token Vault: exchanges the user's Auth0 refresh token for the GitHub +// access token of the GitHub identity they logged in with (same mechanism as +// @auth0/nextjs-auth0's getAccessTokenForConnection). + +const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || "webreplay.us.auth0.com" +const CONNECTION = process.env.AUTH0_GITHUB_CONNECTION || "github" + +const cache = new Map() + +export class GithubNotConnectedError extends Error { + detail: string + constructor(detail: string) { + super("github not connected") + this.detail = detail + } +} + +export async function githubTokenForUser(refreshToken: string): Promise { + const hit = cache.get(refreshToken) + if (hit && hit.exp > Date.now() / 1000 + 60) return hit.token + + const res = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: + "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token", + client_id: process.env.AUTH0_CLIENT_ID ?? "", + client_secret: process.env.AUTH0_CLIENT_SECRET ?? "", + subject_token_type: "urn:ietf:params:oauth:token-type:refresh_token", + subject_token: refreshToken, + connection: CONNECTION, + requested_token_type: + "http://auth0.com/oauth/token-type/federated-connection-access-token", + }), + }) + if (!res.ok) { + throw new GithubNotConnectedError(await res.text()) + } + const body = (await res.json()) as { access_token: string; expires_in?: number } + cache.set(refreshToken, { + token: body.access_token, + exp: Date.now() / 1000 + (body.expires_in ?? 3600), + }) + return body.access_token +} diff --git a/apps/blamy-notes/netlify/functions/lib/github.ts b/apps/blamy-notes/netlify/functions/lib/github.ts new file mode 100644 index 0000000..5276a0f --- /dev/null +++ b/apps/blamy-notes/netlify/functions/lib/github.ts @@ -0,0 +1,147 @@ +// GitHub REST helpers operating with the USER's own access token (obtained +// via Auth0 Token Vault), so all access is scoped to what the logged-in +// GitHub user can see, and commits/PRs are authored as that user. + +const API = "https://api.github.com" + +async function gh(path: string, token: string, init?: RequestInit) { + const res = await fetch(`${API}${path}`, { + ...init, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "x-github-api-version": "2022-11-28", + ...(init?.headers ?? {}), + }, + }) + if (!res.ok) { + throw new Error(`GitHub ${init?.method ?? "GET"} ${path} -> ${res.status}: ${await res.text()}`) + } + return res.status === 204 ? null : res.json() +} + +const b64encode = (s: string) => Buffer.from(s, "utf8").toString("base64") +const b64decode = (s: string) => Buffer.from(s, "base64").toString("utf8") + +export interface Profile { + user: { login: string; avatar: string } + orgs: Array<{ login: string; avatar: string }> +} + +/** The logged-in user and the orgs they belong to (for the owner switcher). */ +export async function userProfile(token: string): Promise { + const user = (await gh("/user", token)) as { login: string; avatar_url: string } + const orgs = (await gh("/user/orgs?per_page=100", token).catch(() => [])) as Array<{ + login: string + avatar_url: string + }> + return { + user: { login: user.login, avatar: user.avatar_url }, + orgs: orgs.map((o) => ({ login: o.login, avatar: o.avatar_url })), + } +} + +/** Repos the user can access, most recently pushed first. */ +export async function listUserRepos(token: string): Promise { + const repos: Array<{ full_name: string }> = [] + for (let page = 1; page <= 5; page++) { + const res = (await gh( + `/user/repos?sort=pushed&per_page=100&page=${page}`, + token + )) as Array<{ full_name: string }> + repos.push(...res) + if (res.length < 100) break + } + return repos.map((r) => r.full_name) +} + +/** All markdown file paths in the repo's default branch, recursively. */ +export async function listMarkdownTree( + token: string, + fullName: string +): Promise<{ branch: string; files: string[] }> { + const repo = (await gh(`/repos/${fullName}`, token)) as { default_branch: string } + const tree = (await gh( + `/repos/${fullName}/git/trees/${repo.default_branch}?recursive=1`, + token + )) as { tree: Array<{ path: string; type: string }> } + return { + branch: repo.default_branch, + files: tree.tree + .filter((e) => e.type === "blob" && e.path.endsWith(".md")) + .map((e) => e.path) + .sort(), + } +} + +export async function getFile( + token: string, + fullName: string, + path: string +): Promise<{ content: string; sha: string }> { + const file = (await gh( + `/repos/${fullName}/contents/${encodeURIComponent(path)}`, + token + )) as { content: string; sha: string } + return { content: b64decode(file.content.replace(/\n/g, "")), sha: file.sha } +} + +/** Commits one file directly to the default branch. */ +export async function commitFile( + token: string, + fullName: string, + path: string, + content: string, + message: string, + sha?: string +): Promise<{ commitUrl: string }> { + const res = (await gh(`/repos/${fullName}/contents/${encodeURIComponent(path)}`, token, { + method: "PUT", + body: JSON.stringify({ + message, + content: b64encode(content), + ...(sha ? { sha } : {}), + }), + })) as { commit: { html_url: string } } + return { commitUrl: res.commit.html_url } +} + +/** Commits one file to a new branch and opens a pull request. */ +export async function openPullRequest( + token: string, + fullName: string, + path: string, + content: string, + message: string, + sha?: string +): Promise<{ prUrl: string; number: number }> { + const repo = (await gh(`/repos/${fullName}`, token)) as { default_branch: string } + const head = (await gh( + `/repos/${fullName}/git/ref/heads/${repo.default_branch}`, + token + )) as { object: { sha: string } } + const branch = `blamy-notes/${path.replace(/[^a-zA-Z0-9]+/g, "-")}-${Date.now().toString(36)}` + await gh(`/repos/${fullName}/git/refs`, token, { + method: "POST", + body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: head.object.sha }), + }) + await gh(`/repos/${fullName}/contents/${encodeURIComponent(path)}`, token, { + method: "PUT", + body: JSON.stringify({ + message, + content: b64encode(content), + branch, + ...(sha ? { sha } : {}), + }), + }) + const pr = (await gh(`/repos/${fullName}/pulls`, token, { + method: "POST", + body: JSON.stringify({ + title: message, + head: branch, + base: repo.default_branch, + body: `Docs update to \`${path}\` from [blamy-notes](https://blamy-notes.netlify.app).`, + }), + })) as { html_url: string; number: number } + return { prUrl: pr.html_url, number: pr.number } +} diff --git a/apps/blamy-notes/package.json b/apps/blamy-notes/package.json new file mode 100644 index 0000000..1eb72dc --- /dev/null +++ b/apps/blamy-notes/package.json @@ -0,0 +1,71 @@ +{ + "name": "blamy-notes", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "start": "netlify dev", + "build": "tsc -b && vite build", + "lint": "eslint .", + "format": "prettier --write \"**/*.{ts,tsx}\"", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@netlify/blobs": "^10.7.9", + "@tailwindcss/vite": "^4", + "@tanstack/react-query": "^5.101.0", + "@tiptap/extension-code-block-lowlight": "^3.26.1", + "@tiptap/extension-placeholder": "^3.26.1", + "@tiptap/extension-table": "^3.26.1", + "@tiptap/pm": "^3.26.1", + "@tiptap/react": "^3.26.1", + "@tiptap/starter-kit": "^3.26.1", + "@tiptap/suggestion": "^3.26.1", + "ajv": "^8.20.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.4.0", + "hast-util-to-html": "^9.0.5", + "jose": "^6.2.3", + "lowlight": "^3.3.0", + "lucide-react": "^1.17.0", + "mermaid": "^11.15.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.5.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "shadcn": "^4.11.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "yaml": "^2.9.0", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@eslint/js": "^10", + "@netlify/functions": "^5.3.0", + "@types/node": "^24", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6", + "eslint": "^10", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17", + "netlify-cli": "^26.1.0", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.8.0", + "typescript": "~6", + "typescript-eslint": "^8", + "vite": "^8" + }, + "overrides": { + "ajv-errors": { + "ajv": "^8" + } + } +} diff --git a/apps/blamy-notes/project.json b/apps/blamy-notes/project.json new file mode 100644 index 0000000..389a9b7 --- /dev/null +++ b/apps/blamy-notes/project.json @@ -0,0 +1,36 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "blamy-notes", + "projectType": "application", + "root": "apps/blamy-notes", + "sourceRoot": "apps/blamy-notes/src", + "targets": { + "build": { + "executor": "nx:run-commands", + "outputs": [ + "{projectRoot}/dist" + ], + "options": { + "command": "npm run build --workspace=apps/blamy-notes" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "npm run lint --workspace=apps/blamy-notes" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "npm run typecheck --workspace=apps/blamy-notes" + } + }, + "dev": { + "executor": "nx:run-commands", + "options": { + "command": "npm run dev --workspace=apps/blamy-notes" + } + } + } +} diff --git a/apps/blamy-notes/public/vite.svg b/apps/blamy-notes/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/blamy-notes/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/blamy-notes/scripts/fixtures/gitbook-export.md b/apps/blamy-notes/scripts/fixtures/gitbook-export.md new file mode 100644 index 0000000..1e1c69a --- /dev/null +++ b/apps/blamy-notes/scripts/fixtures/gitbook-export.md @@ -0,0 +1,164 @@ +# Page + +pasdfasdf + +## asdfasdf + +### asdfasdf + + + +#### asdfasdf + + + +* asdfasdf +* asdfasdf + +1. safasfd +2. asdfasdf + + + +* [ ] asdfasdf +* [ ] asdfasdf + +*** + + + +{% hint style="info" %} +asdfasdf +{% endhint %} + + + +> asdfasdf + +``` +// Some codeasdf + + + + +``` + +```mermaid +graph TD + Mermaid --> Diagram +``` + +$$ +f(x) = x * e^{2 pi i \xi x} +$$ + +| | | | +| - | - | - | +| | | | +| | | | +| | | | + +
    + +{% tabs %} +{% tab title="First Tab" %} + +{% endtab %} + +{% tab title="Second Tab" %} + +{% endtab %} +{% endtabs %} + +
    + +asdf + +asdfasdf + +
    + + + +{% stepper %} +{% step %} +### step1 + +CONTENT +{% endstep %} + +{% step %} +### STEP 2 + +CONTENT +{% endstep %} +{% endstepper %} + + + +{% updates format="full" %} +{% update date="2026-06-11" %} +## ASDFASDF + +ASDFASDF +{% endupdate %} + +{% update date="2026-06-11" %} +## + + +{% endupdate %} + +{% update date="2026-06-11" %} +## + + + + + + + + +{% endupdate %} +{% endupdates %} + + + +{% openapi-operation spec="gitbook-petstore" path="/store/orders" method="get" %} +[OpenAPI gitbook-petstore](https://gitbookio.github.io/onboarding-template-images/gitbook-petstore.yaml) +{% endopenapi-operation %} + +{% columns %} +{% column %} + +{% endcolumn %} + +{% column %} + +{% endcolumn %} +{% endcolumns %} + + + +{% tabs %} +{% tab title="JavaScript" %} +```javascript +const message = "hello world"; +console.log(message); +``` +{% endtab %} + +{% tab title="Python" %} +```python +message = "hello world" +print(message) +``` +{% endtab %} + +{% tab title="Ruby" %} +```ruby +message = "hello world" +puts message +``` +{% endtab %} +{% endtabs %} diff --git a/apps/blamy-notes/scripts/roundtrip-test.ts b/apps/blamy-notes/scripts/roundtrip-test.ts new file mode 100644 index 0000000..43656e9 --- /dev/null +++ b/apps/blamy-notes/scripts/roundtrip-test.ts @@ -0,0 +1,217 @@ +import { parseMarkdown } from "../src/gitbook/parse" +import { serializeMarkdown } from "../src/gitbook/serialize" + +const sample = `# Getting Started + +Welcome to **blamy-notes** — a _GitBook-style_ docs platform with \`inline code\` and [links](https://example.com). + +{% hint style="warning" %} +Heads up! This is a **warning** hint. +{% endhint %} + +{% tabs %} +{% tab title="npm" %} +\`\`\`bash +npm install +\`\`\` +{% endtab %} + +{% tab title="yarn" %} +\`\`\`bash +yarn +\`\`\` +{% endtab %} +{% endtabs %} + +
    + +Click to expand + +Hidden content with a [link](https://x.com). + +
    + +{% stepper %} +{% step %} +### Install dependencies + +Run the installer. +{% endstep %} + +{% step %} +### Start the server + +Then visit localhost. +{% endstep %} +{% endstepper %} + +{% code title="server.ts" lineNumbers="true" %} +\`\`\`typescript +const x: number = 1 +\`\`\` +{% endcode %} + +{% embed url="https://www.youtube.com/watch?v=abc123" %} + +{% content-ref url="getting-started/install.md" %} +Install guide +{% endcontent-ref %} + +{% columns %} +{% column %} +Left side +{% endcolumn %} + +{% column %} +Right side +{% endcolumn %} +{% endcolumns %} + +
    Hero

    The hero image

    + +## Lists + +- one +- two **bold** + +1. first +2. second + +- [x] done task +- [ ] open task + +> A wise quote +> spanning lines. + +| Col A | Col B | +| --- | --- | +| 1 | 2 | + +$$ +e = mc^2 +$$ + +--- + +The end. +` + +const ast1 = parseMarkdown(sample) +const md1 = serializeMarkdown(ast1) +const ast2 = parseMarkdown(md1) +const md2 = serializeMarkdown(ast2) + +if (JSON.stringify(ast1) !== JSON.stringify(ast2)) { + console.error("✗ AST not stable across round-trip") + const a = JSON.stringify(ast1, null, 1).split("\n") + const b = JSON.stringify(ast2, null, 1).split("\n") + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if (a[i] !== b[i]) { + console.error(`first diff at line ${i}:\n 1: ${a[i]}\n 2: ${b[i]}`) + break + } + } + process.exit(1) +} +if (md1 !== md2) { + console.error("✗ markdown not stable across second round-trip") + process.exit(1) +} + +const blockTypes = ast1.children.map((b) => b.type) +console.log("✓ round-trip stable") +console.log("block types:", blockTypes.join(", ")) + +// --- Real GitBook export fixture --- +import { readFileSync } from "node:fs" +const fixture = readFileSync(new URL("./fixtures/gitbook-export.md", import.meta.url), "utf8") +const f1 = parseMarkdown(fixture) +const fmd1 = serializeMarkdown(f1) +const f2 = parseMarkdown(fmd1) +if (JSON.stringify(f1) !== JSON.stringify(f2)) { + console.error("✗ fixture AST not stable") + const a = JSON.stringify(f1, null, 1).split("\n") + const b = JSON.stringify(f2, null, 1).split("\n") + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if (a[i] !== b[i]) { + console.error(`first diff at line ${i}:\n 1: ${a[i]}\n 2: ${b[i]}`) + break + } + } + process.exit(1) +} +if (fmd1 !== serializeMarkdown(f2)) { + console.error("✗ fixture markdown not stable") + process.exit(1) +} +console.log("✓ gitbook export fixture round-trip stable") +console.log("fixture block types:", f1.children.map((b) => b.type).join(", ")) + +// --- GitHub README badge row --- +const badges = `Hi, I'm Brett. i like computers\n\n \n` +const b1 = parseMarkdown(badges) +const bmd = serializeMarkdown(b1) +const b2 = parseMarkdown(bmd) +if (JSON.stringify(b1) !== JSON.stringify(b2)) { + console.error("✗ badge AST unstable"); process.exit(1) +} +const imgs = b1.children[1] +if (imgs.type !== "paragraph" || imgs.children.filter((c) => c.type === "image").length !== 4) { + console.error("✗ expected 4 inline images, got:", JSON.stringify(imgs).slice(0, 200)); process.exit(1) +} +if (!bmd.includes('')) { + console.error("✗ badge serialization changed:", bmd); process.exit(1) +} +console.log("✓ github badge row round-trip stable (4 inline images)") + +// --- GFM features --- +const gfm = [ + "Setext H1", + "=========", + "", + "Setext H2", + "---------", + "", + "- top", + " - nested", + " - deeper", + "- top2", + "", + "1. one", + " 1. one.one", + "2. two", + "", + "~~~js", + "const tilde = true", + "~~~", + "", + " indented code", + " line two", + "", + "Visit https://example.com/a_b and .", + "", + "See [the docs][docs] and [GitHub][].", + "", + "[docs]: https://docs.example.com", + "[github]: https://github.com", +].join("\n") +const g1 = parseMarkdown(gfm) +const gmd = serializeMarkdown(g1) +const g2 = parseMarkdown(gmd) +if (JSON.stringify(g1) !== JSON.stringify(g2)) { + console.error("✗ GFM AST unstable") + const a = JSON.stringify(g1, null, 1).split("\n"), b = JSON.stringify(g2, null, 1).split("\n") + for (let i = 0; i < Math.max(a.length, b.length); i++) if (a[i] !== b[i]) { console.error(`diff@${i}\n 1: ${a[i]}\n 2: ${b[i]}`); break } + process.exit(1) +} +const types = g1.children.map((b) => b.type).join(",") +const nested = g1.children[2] +const ok = + g1.children[0].type === "heading" && g1.children[0].level === 1 && + g1.children[1].type === "heading" && g1.children[1].level === 2 && + nested.type === "list" && nested.items[0].children.some((c) => c.type === "list") && + g1.children[4].type === "code" && g1.children[5].type === "code" +if (!ok) { console.error("✗ GFM structure wrong:", types); process.exit(1) } +const links = g1.children.filter((b) => b.type === "paragraph").flatMap((p) => p.children).filter((c) => c.type === "text" && c.link) +if (links.length < 4) { console.error("✗ expected 4 links, got", links.length); process.exit(1) } +console.log("✓ GFM round-trip stable (setext, nested lists, ~~~, indented code, autolinks, ref links)") diff --git a/apps/blamy-notes/scripts/set-netlify-private-key.py b/apps/blamy-notes/scripts/set-netlify-private-key.py new file mode 100644 index 0000000..a6b5cf1 --- /dev/null +++ b/apps/blamy-notes/scripts/set-netlify-private-key.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Set GITHUB_APP_PRIVATE_KEY as a secret Netlify env var. + +Reads the PEM from disk and the Netlify auth token from the CLI config, +so the secret never appears in process arguments. +""" +import json +import pathlib +import urllib.request + +SITE_ID = "cacd57f2-12f6-46a9-85bc-9c00ea426097" # blamy-notes +ACCOUNT_SLUG = "brett-lamy" + +pem = pathlib.Path(__file__).resolve().parent.parent / "github-app-private-key.pem" +key_value = pem.read_text() + +config = json.loads( + (pathlib.Path.home() / "Library/Preferences/netlify/config.json").read_text() +) +users = config["users"] +token = next(iter(users.values()))["auth"]["token"] + +payload = [ + { + "key": "GITHUB_APP_PRIVATE_KEY", + "scopes": ["builds", "functions", "runtime", "post-processing"], + "values": [{"value": key_value, "context": "all"}], + "is_secret": True, + } +] + +req = urllib.request.Request( + f"https://api.netlify.com/api/v1/accounts/{ACCOUNT_SLUG}/env?site_id={SITE_ID}", + data=json.dumps(payload).encode(), + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + method="POST", +) +with urllib.request.urlopen(req) as res: + body = json.load(res) + print(res.status, [v["key"] for v in body]) diff --git a/apps/blamy-notes/src/App.tsx b/apps/blamy-notes/src/App.tsx new file mode 100644 index 0000000..86a7b3c --- /dev/null +++ b/apps/blamy-notes/src/App.tsx @@ -0,0 +1,498 @@ +import { useCallback, useEffect, useMemo, useState } from "react" +import { useQuery } from "@tanstack/react-query" +import { + Book, + Check, + ChevronDown, + ChevronRight, + Eye, + FileText, + Folder, + GitBranch, + GitPullRequest, + Loader2, + LogOut, + PenLine, + Search, + X, +} from "lucide-react" +import { toast } from "sonner" + +import { GitbookEditor } from "@/editor/Editor" +import { DocsRenderer } from "@/docs/DocsRenderer" +import { parseMarkdown } from "@/gitbook/parse" +import { api } from "@/lib/api" +import { setAssetBase } from "@/lib/assets" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +type View = "edit" | "preview" | "markdown" + + +// ---------- File tree ---------- + +interface TreeDir { + dirs: Map + files: string[] // full paths +} + +function buildTree(paths: string[]): TreeDir { + const root: TreeDir = { dirs: new Map(), files: [] } + for (const path of paths) { + const parts = path.split("/") + let node = root + for (const part of parts.slice(0, -1)) { + if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] }) + node = node.dirs.get(part)! + } + node.files.push(path) + } + return root +} + +function FileTree({ + node, + name, + depth, + selected, + onSelect, +}: { + node: TreeDir + name?: string + depth: number + selected: string | null + onSelect: (path: string) => void +}) { + const [open, setOpen] = useState(depth < 1) + const inner = ( + <> + {[...node.dirs.entries()].map(([dir, child]) => ( + + ))} + {node.files.map((path) => ( + + ))} + + ) + if (name === undefined) return inner + return ( +
    + + {open && inner} +
    + ) +} + +// ---------- App ---------- + +export default function App() { + const [repo, setRepo] = useState(null) + const [filePath, setFilePath] = useState(null) + const [view, setView] = useState("edit") + const [owner, setOwner] = useState(null) + const [ownerMenuOpen, setOwnerMenuOpen] = useState(false) + const [search, setSearch] = useState("") + + // Repos come from the logged-in user's own GitHub identity (Auth0 Token + // Vault exchanges the session's refresh token for their GitHub token). + const reposQuery = useQuery({ + queryKey: ["repos"], + queryFn: api.githubRepos, + staleTime: 5 * 60_000, + retry: false, + }) + const profile = useQuery({ + queryKey: ["profile"], + queryFn: api.githubProfile, + staleTime: 10 * 60_000, + retry: false, + }) + const allRepos = useMemo(() => reposQuery.data ?? [], [reposQuery.data]) + const githubNotConnected = + reposQuery.isError && String(reposQuery.error).includes("github_not_connected") + + // Owner switcher: the user plus their orgs (plus any other owners that + // appear among accessible repos, e.g. collaborator repos). + const owners = useMemo(() => { + const known = new Map() // login -> avatar + if (profile.data) { + known.set(profile.data.user.login, profile.data.user.avatar) + for (const o of profile.data.orgs) known.set(o.login, o.avatar) + } + for (const r of allRepos) { + const o = r.split("/")[0] + if (!known.has(o)) known.set(o, `https://github.com/${o}.png?size=48`) + } + return [...known.entries()].map(([login, avatar]) => ({ login, avatar })) + }, [profile.data, allRepos]) + + // Default to the personal account once known. + useEffect(() => { + if (!owner && profile.data) setOwner(profile.data.user.login) + }, [owner, profile.data]) + + const activeOwner = owners.find((o) => o.login === owner) ?? owners[0] ?? null + + // No query: show the active owner's repos. With a query: search EVERY repo + // the user can access, across all owners. + const searching = search.trim().length > 0 + const repos = useMemo(() => { + const q = search.trim().toLowerCase() + if (q) return allRepos.filter((r) => r.toLowerCase().includes(q)) + return allRepos.filter((r) => !activeOwner || r.split("/")[0] === activeOwner.login) + }, [allRepos, activeOwner, search]) + + const tree = useQuery({ + queryKey: ["tree", repo], + queryFn: () => api.repoTree(repo!), + enabled: !!repo, + staleTime: 60_000, + }) + + const file = useQuery({ + queryKey: ["file", repo, filePath], + queryFn: () => api.repoFile(repo!, filePath!), + enabled: !!repo && !!filePath, + }) + + // Local editing buffer; synced to GitHub only on demand. + const [markdown, setMarkdown] = useState("") + const [dirty, setDirty] = useState(false) + const [syncing, setSyncing] = useState<"main" | "pr" | null>(null) + + useEffect(() => { + if (file.data) { + setMarkdown(file.data.content) + setDirty(false) + } + }, [file.data]) + + // Relative image srcs in the file resolve against the repo's raw URL. + useEffect(() => { + setAssetBase(repo, tree.data?.branch ?? null, filePath) + }, [repo, tree.data?.branch, filePath]) + + const handleChange = useCallback((md: string) => { + setMarkdown(md) + setDirty(true) + }, []) + + const selectRepo = (r: string) => { + if (dirty && !window.confirm("Discard unsaved changes?")) return + setRepo(r === repo ? null : r) + setFilePath(null) + setDirty(false) + } + + const selectFile = (path: string) => { + if (dirty && !window.confirm("Discard unsaved changes?")) return + setFilePath(path) + setDirty(false) + } + + const [pendingMode, setPendingMode] = useState<"main" | "pr" | null>(null) + const [commitMessage, setCommitMessage] = useState("") + + const openSyncPanel = (mode: "main" | "pr") => { + setCommitMessage(`docs: update ${filePath}`) + setPendingMode(mode) + } + + const sync = async (mode: "main" | "pr", message: string) => { + if (!repo || !filePath || !file.data || !message) return + setPendingMode(null) + setSyncing(mode) + try { + const result = await api.repoSave(repo, { + path: filePath, + content: markdown, + message, + mode, + sha: file.data.sha, + }) + setDirty(false) + if (result.prUrl) { + toast.success(`Opened PR #${result.number}`, { + action: { label: "View", onClick: () => window.open(result.prUrl, "_blank") }, + }) + } else { + toast.success(`Committed to ${tree.data?.branch ?? "main"}`, { + action: { label: "View", onClick: () => window.open(result.commitUrl, "_blank") }, + }) + } + file.refetch() + } catch (e) { + toast.error(String(e instanceof Error ? e.message : e)) + } finally { + setSyncing(null) + } + } + + const logout = async () => { + await api.logout() + window.location.reload() + } + + return ( +
    + + +
    +
    + + {filePath ? ( + <> + {filePath} + + ) : ( + No file selected + )} + +
    + {( + [ + ["edit", PenLine, "Editor"], + ["preview", Eye, "Preview"], + ["markdown", FileText, "Markdown"], + ] as const + ).map(([v, Icon, label]) => ( + + ))} +
    + {filePath && ( +
    + + {dirty ? ( + "Unsaved changes" + ) : ( + <> + Synced + + )} + + + +
    + )} +
    + + {pendingMode && ( +
    + setCommitMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") sync(pendingMode, commitMessage) + if (e.key === "Escape") setPendingMode(null) + }} + /> + + +
    + )} + +
    + {!repo ? ( +
    Select a repository to browse its markdown files.
    + ) : !filePath ? ( +
    Select a markdown file from the tree.
    + ) : file.isLoading ? ( +
    Loading {filePath}…
    + ) : file.isError ? ( +
    Failed to load file: {String(file.error)}
    + ) : view === "edit" ? ( + + ) : view === "preview" ? ( + + ) : ( +