22 Commits
Author SHA1 Message Date
rmcampos 3cc6295226 Merge pull request 'chore(deps): update dependency typescript to v7' (#2) from renovate/typescript-7.x into main
Frontend CD / build (push) Successful in 5m15s
Reviewed-on: #2
Reviewed-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-07-10 19:54:50 +00:00
rmcampos 4d75660de8 Merge branch 'main' into renovate/typescript-7.x 2026-07-10 21:54:12 +02:00
rmcampos 08a61569f4 chore: bump minor deps
Frontend CD / build (push) Successful in 5m31s
2026-07-10 21:47:21 +02:00
renovate-bot 7a0fc1b801 chore(deps): update node.js to v24 2026-07-10 16:59:12 +00:00
renovate-bot 1f4bb72b9c chore(deps): update dependency typescript to v7
renovate/artifacts Artifact file update failure
2026-07-10 16:59:11 +00:00
renovate-botandrmcampos 112cd7a404 chore: Configure Renovate (#1)
Frontend CD / build (push) Successful in 1m14s
Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin.

🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged.

📚 See our [Reading List](https://docs.renovatebot.com/reading-list/) for relevant documentation you may be interested in reading.

🔡 Do you want to change how Renovate upgrades your dependencies? Add your custom config to `renovate.json` in this branch. Renovate will update the Pull Request description the next time it runs.

---
### Detected Package Files

 * `Dockerfile` (dockerfile)
 * `package.json` (npm)

### Configuration Summary

Based on the default config's presets, Renovate will:

  - Start dependency updates only once this onboarding PR is merged
  - Enable Renovate Dependency Dashboard creation.
  - Use semantic commit type `fix` for dependencies and `chore` for all others if semantic commits are in use.
  - Ignore `node_modules`, `bower_components`, `vendor` and various test/tests (except for nuget) directories.
  - Group known monorepo packages together.
  - Use curated list of recommended non-monorepo package groupings.
  - Show only the Age and Confidence Merge Confidence badges for pull requests.
  - Apply crowd-sourced package replacement rules.
  - Apply crowd-sourced workarounds for known problems with packages.
  - Ensure that every dependency pinned by digest and sourced from Forgejo contains a link to the commit-to-commit diff
  - Ensure that every dependency pinned by digest and sourced from Gitea contains a link to the commit-to-commit diff
  - Ensure that every dependency pinned by digest and sourced from GitHub.com and Github enterprise contains a link to the commit-to-commit diff
  - Ensure that every dependency pinned by digest and sourced from GitLab.com contains a link to the commit-to-commit diff
  - Correctly link to the source code for golang.org/x packages
  - Link to pkg.go.dev/... for golang.org/x packages' title
  - Provide a link to octochangelog's improved breakdown for Renovate's changelogs

---

### What to Expect

With your current configuration, Renovate will create 9 Pull Requests:

<details>
<summary>chore(deps): update dependency @&#8203;clerk/tanstack-react-start to v1.4.12</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/clerk-tanstack-react-start-1.x-lockfile`
  - Merge into: `main`
  - Upgrade [@clerk/tanstack-react-start](https://github.com/clerk/javascript) to `1.4.12`

</details>

<details>
<summary>chore(deps): update dependency @&#8203;tanstack/router-plugin to v1.168.19</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/tanstack-router-monorepo`
  - Merge into: `main`
  - Upgrade [@tanstack/router-plugin](https://github.com/TanStack/router) to `1.168.19`

</details>

<details>
<summary>chore(deps): update dependency convex to v1.42.1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/convex-1.x-lockfile`
  - Merge into: `main`
  - Upgrade [convex](https://github.com/get-convex/convex-backend) to `1.42.1`

</details>

<details>
<summary>chore(deps): update dependency radix-ui to v1.6.1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/radix-ui-primitives-monorepo`
  - Merge into: `main`
  - Upgrade [radix-ui](https://github.com/radix-ui/primitives) to `1.6.1`

</details>

<details>
<summary>chore(deps): update dependency vite to v8.1.3</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/vite-8.x-lockfile`
  - Merge into: `main`
  - Upgrade [vite](https://github.com/vitejs/vite) to `8.1.3`

</details>

<details>
<summary>chore(deps): update tailwindcss monorepo to v4.3.2</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/tailwindcss-monorepo`
  - Merge into: `main`
  - Upgrade [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss) to `4.3.2`
  - Upgrade [tailwindcss](https://github.com/tailwindlabs/tailwindcss) to `4.3.2`

</details>

<details>
<summary>chore(deps): update dependency @&#8203;types/node to v26.1.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/node-26.x-lockfile`
  - Merge into: `main`
  - Upgrade [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped) to `26.1.0`

</details>

<details>
<summary>chore(deps): update dependency lucide-react to v1.23.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/lucide-monorepo`
  - Merge into: `main`
  - Upgrade [lucide-react](https://github.com/lucide-icons/lucide) to `1.23.0`

</details>

<details>
<summary>chore(deps): update node.js to v24</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/node-24.x`
  - Merge into: `main`
  - Upgrade [node](https://github.com/nodejs/node) to `24-alpine`

</details>

🚸 PR creation will be limited to maximum 2 per hour, so it doesn't swamp any CI resources or overwhelm the project. See [docs for `prHourlyLimit`](https://docs.renovatebot.com/configuration-options/#prhourlylimit) for details.

---

 Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).

<!--renovate-config-hash:e80b4e42a3043bc12fa0640db4bac392d2bf770acf841360d7c8ceeeac2ec1a9-->

Reviewed-on: #1
Co-authored-by: Renovate Bot <renovate-bot@lightroasted.vps-kinghost.net>
Co-committed-by: Renovate Bot <renovate-bot@lightroasted.vps-kinghost.net>
2026-07-05 21:05:25 +00:00
rmcampos 520e55563d chore: update docs and bump dependencies
Frontend CD / build (push) Successful in 5m21s
2026-06-25 10:02:27 -03:00
rmcampos 989ad65046 Update .github/workflows/deploy-frontend.yml 2026-06-18 16:09:24 +00:00
rmcampos 813a189745 feat: replace ghcr by docker and update workflows for gitea
Frontend CD / build (push) Successful in 3m30s
2026-06-18 17:05:51 +02:00
rmcampos 0055e1cf0d chore: bump dependencies 2026-06-18 16:46:59 +02:00
rmcampos 6c1213cb83 chore: cleanup files 2026-06-18 16:46:34 +02:00
rmcamposandGitHub 908dd155a0 fix: drop deploy flag from convex deploy 2026-06-03 18:01:01 -03:00
rmcamposandGitHub c368ffd5e8 fix: convex deploy yml file 2026-06-03 17:57:29 -03:00
rmcamposandGitHub bae16f0ef3 fix: frontend yml file workflow 2026-06-03 17:54:42 -03:00
rmcampos 5c7848276f fix: deploy backend to prod 2026-06-03 22:52:06 +02:00
rmcampos ede3ae957f feat: add doppler and taskfile for better usage and workflow 2026-06-03 22:41:15 +02:00
rmcampos b95ac5e07a feat: add option to see book cover bigger 2026-05-29 23:30:01 +02:00
rmcampos 36cead4630 feat: add hit with search options 2026-05-29 23:14:08 +02:00
rmcamposandGitHub 3778237f68 Update feature status in README.md to 'done' 2026-05-29 17:48:10 -03:00
rmcampos 26b106a37a feat: order books alphabetically in the backend 2026-05-29 15:43:31 -03:00
rmcampos 46bc53c5f0 chore: add logs for error debugging in prod 2026-05-29 20:07:58 +02:00
rmcampos 575a93b945 fix: select item error 2026-05-29 20:02:37 +02:00
25 changed files with 2208 additions and 1971 deletions
-100
View File
@@ -1,100 +0,0 @@
This document serves as some special instructions when working with Convex.
# Schemas
When designing the schema please see this page on built in System fields and data types available: https://docs.convex.dev/database/types
Here are some specifics that are often mishandled:
## v (https://docs.convex.dev/api/modules/values#v)
The validator builder.
This builder allows you to build validators for Convex values.
Validators can be used in schema definitions and as input validators for Convex functions.
Type declaration
Name Type
id <TableName>(tableName: TableName) => VId<GenericId<TableName>, "required">
null () => VNull<null, "required">
number () => VFloat64<number, "required">
float64 () => VFloat64<number, "required">
bigint () => VInt64<bigint, "required">
int64 () => VInt64<bigint, "required">
boolean () => VBoolean<boolean, "required">
string () => VString<string, "required">
bytes () => VBytes<ArrayBuffer, "required">
literal <T>(literal: T) => VLiteral<T, "required">
array <T>(element: T) => VArray<T["type"][], T, "required">
object <T>(fields: T) => VObject<Expand<{ [Property in string | number | symbol]?: Exclude<Infer<T[Property]>, undefined> } & { [Property in string | number | symbol]: Infer<T[Property]> }>, T, "required", { [Property in string | number | symbol]: Property | `${Property & string}.${T[Property]["fieldPaths"]}` }[keyof T] & string>
record <Key, Value>(keys: Key, values: Value) => VRecord<Record<Infer<Key>, Value["type"]>, Key, Value, "required", string>
union <T>(...members: T) => VUnion<T[number]["type"], T, "required", T[number]["fieldPaths"]>
any () => VAny<any, "required", string>
optional <T>(value: T) => VOptional<T>
## System fields (https://docs.convex.dev/database/types#system-fields)
Every document in Convex has two automatically-generated system fields:
_id: The document ID of the document.
_creationTime: The time this document was created, in milliseconds since the Unix epoch.
You do not need to add indices as these are added automatically.
## Example Schema
This is an example of a well crafted schema.
```ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema(
{
users: defineTable({
name: v.string(),
}),
sessions: defineTable({
userId: v.id("users"),
sessionId: v.string(),
}).index("sessionId", ["sessionId"]),
threads: defineTable({
uuid: v.string(),
summary: v.optional(v.string()),
summarizer: v.optional(v.id("_scheduled_functions")),
}).index("uuid", ["uuid"]),
messages: defineTable({
message: v.string(),
threadId: v.id("threads"),
author: v.union(
v.object({
role: v.literal("system"),
}),
v.object({
role: v.literal("assistant"),
context: v.array(v.id("messages")),
model: v.optional(v.string()),
}),
v.object({
role: v.literal("user"),
userId: v.id("users"),
}),
),
})
.index("threadId", ["threadId"]),
},
);
```
Sourced from: https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/convex-cursorrules-prompt-file/.cursorrules
# shadcn instructions
Use the latest version of Shadcn to install new components, like this command to add a button component:
```bash
pnpm dlx shadcn@latest add button
```
-13
View File
@@ -1,13 +0,0 @@
# Clerk
# Clerk publishable key (required)
VITE_CLERK_PUBLISHABLE_KEY=
# Convex
# Convex deployment name
CONVEX_DEPLOYMENT=
# Convex deployment URL (required)
VITE_CONVEX_URL=
# Set via `npx convex env set` for each deployment (dev and prod).
# Value is the Clerk Frontend API URL from Clerk Dashboard → API Keys.
# CLERK_FRONTEND_API_URL=
+63
View File
@@ -0,0 +1,63 @@
name: Deploy Backend
on:
workflow_dispatch:
workflow_run:
workflows: [ "Frontend CD" ]
types: [ completed ]
jobs:
deploy-convex:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: easynode-debian
permissions:
contents: read
env:
CLERK_PUBLISHABLE_KEY: ""
CLERK_SIGN_IN_FALLBACK_REDIRECT_URL: ""
CLERK_SIGN_IN_URL: ""
CLERK_SIGN_UP_FALLBACK_REDIRECT_URL: ""
CLERK_SIGN_UP_URL: ""
CONVEX_URL: ""
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci
- name: Setup Doppler CLI
uses: dopplerhq/cli-action@v4
- name: Fetch secrets from Doppler
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_TOKENS }}
run: |
doppler secrets download \
--no-file \
--format env \
--project books \
--config prd_tokens \
| sed 's/"//g' >> $GITHUB_ENV
- name: Setup Convex Prod
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: |
echo "CLERK_PUBLISHABLE_KEY=${{ env.CLERK_PUBLISHABLE_KEY }}" > .env.local
echo "CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=${{ env.CLERK_SIGN_IN_FALLBACK_REDIRECT_URL }}" >> .env.local
echo "CLERK_SIGN_IN_URL=${{ env.CLERK_SIGN_IN_URL }}" >> .env.local
echo "CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=${{ env.CLERK_SIGN_UP_FALLBACK_REDIRECT_URL }}" >> .env.local
echo "CLERK_SIGN_UP_URL=${{ env.CLERK_SIGN_UP_URL }}" >> .env.local
echo "CONVEX_URL=${{ env.CONVEX_URL }}" >> .env.local
doppler secrets download --no-file --format env --config prd_secrets >> .env.local
npx convex deploy
@@ -1,4 +1,4 @@
name: Deploy to prod
name: Deploy Frontend to Prod
on:
workflow_dispatch:
@@ -17,14 +17,17 @@ on:
jobs:
terraform-plan:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
runs-on: easynode-debian
outputs:
no_changes: ${{ steps.check-changes.outputs.no_changes }}
permissions:
contents: read
env:
CONVEX_URL: ""
CLERK_PUBLISHABLE_KEY: ""
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -34,10 +37,15 @@ jobs:
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Setup Doppler CLI
uses: dopplerhq/cli-action@v4
- name: Setup Kubeconfig
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
doppler run --config prd_secrets -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
chmod 600 ~/.kube/config
- name: Validate cluster access
@@ -54,7 +62,7 @@ jobs:
echo "latest frontend tag=$latest_frontend_tag"
if [ -z "$frontend_image" ]; then
frontend_image="ghcr.io/rmcampos/books/app:$latest_frontend_tag"
frontend_image="docker.io/rmcampos/books:$latest_frontend_tag"
fi
echo "Resolved frontend_image=$frontend_image"
@@ -68,24 +76,30 @@ jobs:
- name: Terraform Init
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd_secrets -- terraform init -input=false
- name: Terraform Validate
working-directory: terraform
run: terraform validate
- name: Fetch public tokens
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_TOKENS }}
run: |
doppler secrets download --no-file --format env \
--config prd_tokens | sed 's/"//g' >> $GITHUB_ENV
- name: Terraform Plan
id: check-changes
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: |
doppler run --config prd_secrets -- \
timeout 1m terraform plan -input=false -out=tfplan \
-var="convex_url=${{ secrets.VITE_CONVEX_URL }}" \
-var="clerk_publishable_key=${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}" \
-var="convex_url=${{ env.CONVEX_URL }}" \
-var="clerk_publishable_key=${{ env.CLERK_PUBLISHABLE_KEY }}" \
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
terraform show -json tfplan > tfplan.json
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
@@ -98,13 +112,13 @@ jobs:
fi
- name: Upload plan artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: tfplan
path: terraform/tfplan
terraform-apply:
runs-on: ubuntu-latest
runs-on: easynode-debian
needs: terraform-plan
if: >
(github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
@@ -116,33 +130,37 @@ jobs:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Download plan artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: tfplan
path: terraform
- name: Setup Doppler CLI
uses: dopplerhq/cli-action@v4
- name: Setup Kubeconfig
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
doppler run --config prd_secrets -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
chmod 600 ~/.kube/config
- name: Terraform Init
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd_secrets -- terraform init -input=false
- name: Terraform Apply
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: timeout 1m terraform apply tfplan
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd_secrets -- timeout 1m terraform apply tfplan
+38 -20
View File
@@ -15,10 +15,13 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: easynode-debian
permissions:
contents: write
packages: write
env:
CONVEX_URL: ""
CLERK_PUBLISHABLE_KEY: ""
steps:
- name: Generate version tag
id: version
@@ -31,53 +34,68 @@ jobs:
echo "Generated tag: ${TAG}"
echo "Generated build number: ${BUILD_NUMBER}"
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node.js 22.x
uses: actions/setup-node@v4
- name: Cache npm dependencies
uses: actions/cache@v3
with:
node-version: 22.x
cache: 'npm'
cache-dependency-path: package-lock.json
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Setup Doppler CLI
uses: dopplerhq/cli-action@v4
- name: Fetch secrets from Doppler
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_TOKENS }}
run: |
doppler secrets download \
--no-file \
--format env \
--project books \
--config prd_tokens \
| sed 's/"//g' >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
run: docker buildx inspect --bootstrap
- name: Login to GitHub Container Registry
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/app
images: rmcampos/books
tags: |
type=raw,value=${{ steps.version.outputs.tag }}
type=raw,value=latest,enable={{ is_default_branch }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
sbom: false
cache-from: type=registry,ref=rmcampos/books:buildcache
cache-to: type=registry,ref=rmcampos/books:buildcache,mode=max
build-args: |
VITE_BUILD_NUMBER=${{ steps.version.outputs.build_number }}
VITE_CONVEX_URL=${{ secrets.VITE_CONVEX_URL }}
VITE_CLERK_PUBLISHABLE_KEY=${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
VITE_CONVEX_URL=${{ env.CONVEX_URL }}
VITE_CLERK_PUBLISHABLE_KEY=${{ env.CLERK_PUBLISHABLE_KEY }}
- name: Create and push Git tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
-56
View File
@@ -1,56 +0,0 @@
# Bookshelf — Claude Code Guide
## What This Project Is
A personal book wishlist app built on **Convex** (convex.dev), **TanStack Start** (React), and **Clerk** (auth). The primary goal is learning Convex deeply — every decision should favor Convex-native patterns.
## Before You Act on Any Task
Read these files in order. They are the source of truth for all decisions:
1. `ai/context/project_vision.md` — what we're building and why
2. `ai/context/project_constraints.md` — hard constraints and Convex features that must be used
3. `ai/context/tech_stack.md` — exact technology choices and Convex concept mapping
4. `ai/context/architecture_principles.md` — design rules, including the Convex-specific section
5. `ai/context/domain_glossary.md` — use these terms exactly; inconsistent naming breaks AI quality
6. `ai/context/current_milestone.md` — current phase and open questions
## How to Find the Right Spec
Specs live in `ai/specs/`. Each spec is a numbered directory: `ai/specs/NNN-name/`. Inside each:
- `spec.md` — goal, requirements, acceptance criteria, dependencies
- `architecture.md` — component breakdown, data flow, decisions
- `tasks.md` — implementation checklist
**Before implementing**, confirm:
- The spec's dependencies are already implemented
- There are no open questions in `current_milestone.md` that block this spec
## Key Rules (Non-Negotiable)
- **Convex-only backend.** No Express, no REST API, no other DB.
- **Auth scoping.** Every Convex query/mutation that touches user data must filter by `ctx.auth.getUserIdentity()`. Never trust a userId from the client.
- **Queries are reactive.** All list/detail views use `useQuery`. Do not fetch data in `useEffect`.
- **Actions for external I/O only.** `query` and `mutation` for everything internal; `action` only for calling the book search API.
- **TypeScript strict mode.** No `any`, no untyped Convex validators.
- **Use glossary terms in code.** Variable names, function names, and Convex table names must match the domain glossary.
## Workflow Summary
```
Context (ai/context/) → Specs (ai/specs/) → Implement → Review → Iterate
```
If context files are incomplete → fill them before writing specs.
If no spec exists for the next feature → generate one using `ai/specs/_template/`.
If a spec exists and dependencies are met → implement it.
If implementation is done → verify acceptance criteria in the spec's `spec.md`.
## Process Files
- `ai/orchestration/orchestrator.md` — how to coordinate across phases
- `ai/orchestration/workflow.md` — phase-by-phase decision logic
- `ai/orchestration/context_policy.md` — rules for reading and using context
- `ai/roles/` — behavior guidelines when acting in a specific role
- `ai/contracts/templates/` — structured formats for handoffs, questions, and feedback
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine
FROM node:24-alpine
WORKDIR /app
+8 -5
View File
@@ -1,5 +1,8 @@
# Bookshelf
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Frontend Build](https://lightroasted.vps-kinghost.net/rmcampos/books/actions/workflows/frontend-cd.yml/badge.svg)](https://lightroasted.vps-kinghost.net/rmcampos/books/actions/?workflow=frontend-cd.yml)
A personal book wishlist app built to learn [Convex](https://convex.dev) deeply.
**Stack**: TanStack Start · Convex · Clerk · Tailwind CSS · shadcn/ui
@@ -52,9 +55,9 @@ See `ai/specs/` for the full spec list. Progress is tracked in `ai/context/curre
| Spec | Feature | Status |
|------|---------|--------|
| 001 | Scaffold (auth + Convex wired up) | done |
| 002 | Core wishlist (Book Entry CRUD) | pending |
| 003 | Book search via Google Books API | pending |
| 004 | Shelves (custom collections) | pending |
| 005 | Ratings and reviews | pending |
| 006 | Cover image upload (Convex file storage) | pending |
| 002 | Core wishlist (Book Entry CRUD) | done |
| 003 | Book search via Google Books API | done |
| 004 | Shelves (custom collections) | done |
| 005 | Ratings and reviews | done |
| 006 | Cover image upload (Convex file storage) | done |
| 007 | Stale reading reminder (Convex cron) | pending |
+37
View File
@@ -0,0 +1,37 @@
# https://taskfile.dev
version: '3'
silent: true
tasks:
build-frontend:
desc: Build the books convex app with Docker
cmd: |
export $(doppler secrets download --no-file --format env --config dev_tokens | sed 's/"//g' | xargs) && \
docker build \
--build-arg VITE_CONVEX_URL="$CONVEX_URL" \
--build-arg VITE_CLERK_PUBLISHABLE_KEY="$CLERK_PUBLISHABLE_KEY" \
-t docker.io/rmcampos/books:latest .
dev-run:
desc: Run the books convex app locally for development
defer: rm -rf .env.local
cmd: |
doppler secrets download --no-file --format env --config dev_tokens > .env.local
doppler secrets download --no-file --format env --config dev_secrets >> .env.local
trap "rm -f .env.local" EXIT
npx convex dev
vars:
SHELL: bash
prod-deploy:
desc: Deploy the books convex app for prod
defer: rm -rf .env.local
cmd: |
doppler secrets download --no-file --format env --config prd_tokens > .env.local
doppler secrets download --no-file --format env --config prd_secrets >> .env.local
trap "rm -f .env.local" EXIT
npx convex deploy
vars:
SHELL: bash
+1 -1
View File
@@ -5,4 +5,4 @@ source .env.prod
docker build --no-cache --progress=plain \
--build-arg VITE_CONVEX_URL="$VITE_CONVEX_URL" \
--build-arg VITE_CLERK_PUBLISHABLE_KEY="$VITE_CLERK_PUBLISHABLE_KEY" \
-t ghcr.io/rmcampos/books/app:latest .
-t docker.io/rmcampos/books:latest .
+5 -1
View File
@@ -60,12 +60,16 @@ export const getMyWishlist = query({
.withIndex('by_user', (q) => q.eq('userId', identity.subject))
.collect()
return Promise.all(
const results = await Promise.all(
entries.map(async (entry) => ({
...entry,
book: await ctx.db.get(entry.bookId),
})),
)
return results.sort((a, b) =>
(a.book?.title ?? '').localeCompare(b.book?.title ?? ''),
)
},
})
+4 -3
View File
@@ -18,11 +18,12 @@ export const searchBooks = action({
const res = await fetch(
`https://www.googleapis.com/books/v1/volumes?${params.toString()}`,
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = (await res.json()) as { items?: any[] }
const text = await res.text()
if (!res.ok) {
throw new Error(`Google Books API error: ${res.status}`)
throw new Error(`Google Books API error ${res.status}: ${text}`)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = JSON.parse(text) as { items?: any[] }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (data.items ?? []).map((item: any) => ({
+3
View File
@@ -0,0 +1,3 @@
setup:
project: books
config: dev_secrets
+1742 -1686
View File
File diff suppressed because it is too large Load Diff
+23 -23
View File
@@ -12,40 +12,40 @@
"test": "vitest run"
},
"dependencies": {
"@clerk/clerk-react": "^5.61.3",
"@clerk/tanstack-react-start": "^1.3.1",
"@clerk/clerk-react": "^5.61.8",
"@clerk/tanstack-react-start": "^1.4.16",
"@convex-dev/react-query": "0.1.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/devtools-vite": "^0.7.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/devtools-vite": "^0.8.1",
"@tanstack/react-devtools": "latest",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"@tanstack/router-plugin": "^1.132.0",
"@vitejs/plugin-react": "^6.0.2",
"@tanstack/router-plugin": "^1.168.19",
"@vitejs/plugin-react": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.32.0",
"lucide-react": "^0.577.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.3.6",
"vite": "^8.0.14"
"convex": "^1.42.1",
"lucide-react": "^1.24.0",
"radix-ui": "^1.6.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"vite": "^8.1.4"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"jsdom": "^28.1.0",
"typescript": "^6.0.2",
"vitest": "^4.1.5"
"@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"jsdom": "^29.1.1",
"typescript": "^7.0.0",
"vitest": "^4.1.10"
},
"pnpm": {
"onlyBuiltDependencies": [
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
docker run -it --rm --name books -p 3000:3000 ghcr.io/rmcampos/books/app:latest
docker run -it --rm --name books -p 3000:3000 docker.io/rmcampos/books:latest
+39 -5
View File
@@ -1,13 +1,23 @@
import { useState } from 'react'
import { useAction, useMutation, useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
import { InfoIcon } from 'lucide-react'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
import { BookSearchResult, type SearchResult } from './BookSearchResult'
const SEARCH_OPERATORS = [
{ op: 'isbn:', example: 'isbn:9780743273565', desc: 'Search by ISBN-10 or ISBN-13' },
{ op: 'intitle:', example: 'intitle:gatsby', desc: 'Match words in the title' },
{ op: 'inauthor:', example: 'inauthor:fitzgerald', desc: 'Match words in the author name' },
{ op: 'inpublisher:', example: 'inpublisher:scribner', desc: 'Match by publisher' },
{ op: 'subject:', example: 'subject:fiction', desc: 'Filter by subject or genre' },
]
const LANGUAGE_OPTIONS = [
{ label: 'Default', value: '' },
{ label: 'Default', value: 'default' },
{ label: 'Brazilian', value: 'pt' },
{ label: 'Spanish', value: 'es' },
{ label: 'German', value: 'de' },
@@ -16,7 +26,7 @@ const LANGUAGE_OPTIONS = [
export function BookSearch() {
const [query, setQuery] = useState('')
const [lang, setLang] = useState('')
const [lang, setLang] = useState('default')
const [results, setResults] = useState<SearchResult[] | null>(null)
const [isSearching, setIsSearching] = useState(false)
const [addingId, setAddingId] = useState<string | null>(null)
@@ -36,7 +46,7 @@ export function BookSearch() {
setIsSearching(true)
setSearchError(null)
try {
const found = await searchBooks({ query, ...(lang ? { langRestrict: lang } : {}) })
const found = await searchBooks({ query, ...(lang !== 'default' ? { langRestrict: lang } : {}) })
setResults(found as SearchResult[])
} catch (err) {
console.error('searchBooks failed:', err)
@@ -66,6 +76,7 @@ export function BookSearch() {
<div className="rounded-lg border p-4">
<h2 className="mb-4 font-semibold">Search for a book</h2>
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex flex-1 items-center">
<Input
value={query}
onChange={(e) => {
@@ -75,9 +86,32 @@ export function BookSearch() {
setSearchError(null)
}
}}
placeholder="Title or author…"
className="flex-1"
placeholder="Title, author, isbn:…"
className="flex-1 pr-8"
/>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="absolute right-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Search tips"
>
<InfoIcon className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<p className="mb-2 text-sm font-medium">Search operators</p>
<ul className="flex flex-col gap-2">
{SEARCH_OPERATORS.map(({ op, example, desc }) => (
<li key={op} className="flex flex-col gap-0.5">
<code className="text-xs font-semibold text-foreground">{example}</code>
<span className="text-xs text-muted-foreground">{desc}</span>
</li>
))}
</ul>
</PopoverContent>
</Popover>
</div>
<Select value={lang} onValueChange={setLang}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Language" />
+4 -6
View File
@@ -1,4 +1,5 @@
import { Button } from './ui/button'
import { CoverViewer } from './CoverViewer'
export type SearchResult = {
googleBooksId: string
@@ -19,15 +20,12 @@ interface Props {
export function BookSearchResult({ result, alreadyInWishlist, onAdd, isAdding }: Props) {
return (
<div className="flex gap-3 rounded-lg border p-3">
{result.coverUrl ? (
<img
<CoverViewer
src={result.coverUrl}
alt={result.title}
className="h-20 w-14 shrink-0 rounded object-cover"
className="h-20 w-14 rounded object-cover"
placeholderClassName="h-20 w-14 shrink-0 rounded bg-muted"
/>
) : (
<div className="h-20 w-14 shrink-0 rounded bg-muted" />
)}
<div className="flex min-w-0 flex-1 flex-col justify-between gap-1">
<div>
<p className="font-medium leading-tight">{result.title}</p>
+2 -9
View File
@@ -1,6 +1,7 @@
import { useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
import { CoverViewer } from './CoverViewer'
interface Props {
coverStorageId: Id<'_storage'> | undefined
@@ -16,13 +17,5 @@ export function CoverImage({ coverStorageId, apiCoverUrl, alt }: Props) {
const src = coverStorageId !== undefined ? (storageUrl ?? undefined) : apiCoverUrl
if (!src) {
return (
<div className="flex h-24 w-16 shrink-0 items-center justify-center rounded bg-muted text-2xl text-muted-foreground/40">
📚
</div>
)
}
return <img src={src} alt={alt} className="h-24 w-16 shrink-0 rounded object-cover" />
return <CoverViewer src={src} alt={alt} />
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react'
import { ZoomInIcon } from 'lucide-react'
import { Dialog, DialogContent, DialogTrigger } from './ui/dialog'
import { isGoogleBooksCoverUrl, toHighResCoverUrl } from '#/lib/coverUrl.ts'
interface Props {
src: string | null | undefined
alt: string
className?: string
placeholderClassName?: string
}
export function CoverViewer({ src, alt, className, placeholderClassName }: Props) {
const [loaded, setLoaded] = useState(false)
if (!src) {
return (
<div
className={
placeholderClassName ??
'flex h-24 w-16 shrink-0 items-center justify-center rounded bg-muted text-2xl text-muted-foreground/40'
}
>
📚
</div>
)
}
const hiResSrc = isGoogleBooksCoverUrl(src) ? toHighResCoverUrl(src) : src
return (
<Dialog>
<DialogTrigger asChild>
<button type="button" className="group relative shrink-0 cursor-zoom-in">
<img
src={src}
alt={alt}
className={className ?? 'h-24 w-16 rounded object-cover'}
/>
<span className="absolute inset-0 flex items-center justify-center rounded bg-black/0 opacity-0 transition-all group-hover:bg-black/30 group-hover:opacity-100">
<ZoomInIcon className="size-5 text-white drop-shadow" />
</span>
</button>
</DialogTrigger>
<DialogContent className="p-0">
{!loaded && (
<div className="flex h-64 w-48 items-center justify-center rounded-lg bg-muted text-muted-foreground text-sm">
Loading
</div>
)}
<img
src={hiResSrc}
alt={alt}
onLoad={() => setLoaded(true)}
className={`max-h-[80vh] max-w-[80vw] rounded-lg object-contain ${loaded ? 'block' : 'hidden'}`}
/>
</DialogContent>
</Dialog>
)
}
+56
View File
@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { XIcon } from "lucide-react"
import { cn } from "#/lib/utils.ts"
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background shadow-xl outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-full bg-black/40 p-1 text-white opacity-70 transition-opacity hover:opacity-100">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
export { Dialog, DialogTrigger, DialogContent }
+38
View File
@@ -0,0 +1,38 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "#/lib/utils.ts"
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverTrigger, PopoverContent }
+18
View File
@@ -0,0 +1,18 @@
const GOOGLE_BOOKS_HOST = 'books.google.com'
export function isGoogleBooksCoverUrl(url: string): boolean {
try {
return new URL(url).hostname === GOOGLE_BOOKS_HOST
} catch {
return false
}
}
export function toHighResCoverUrl(url: string, width = 800): string {
if (!isGoogleBooksCoverUrl(url)) return url
const u = new URL(url)
u.searchParams.delete('zoom')
u.searchParams.delete('edge')
u.searchParams.set('fife', `w${width}`)
return u.toString()
}
+1 -1
View File
@@ -35,7 +35,7 @@ variable "clerk_publishable_key" {
variable "frontend_image" {
type = string
default = "ghcr.io/rmcampos/books/app:latest"
default = "docker.io/rmcampos/books:latest"
}
resource "kubernetes_namespace_v1" "books" {