23 changed files with 1568 additions and 1713 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,11 +37,16 @@ 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
chmod 600 ~/.kube/config
doppler run --config prd_secrets -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
chmod 600 ~/.kube/config
- name: Validate cluster access
run: |
@@ -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,27 @@ 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 }}
run: |
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 +109,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 +127,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
chmod 600 ~/.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
+5 -5
View File
@@ -52,9 +52,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
+1120 -1434
View File
File diff suppressed because it is too large Load Diff
+19 -19
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.5",
"@convex-dev/react-query": "0.1.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/devtools-vite": "^0.7.0",
"@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",
"@tanstack/router-plugin": "^1.168.18",
"@vitejs/plugin-react": "^6.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.32.0",
"convex": "^1.41.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"
"radix-ui": "^1.6.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"tw-animate-css": "^1.4.0",
"vite": "^8.0.16"
},
"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",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.19.21",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"jsdom": "^28.1.0",
"typescript": "^6.0.2",
"vitest": "^4.1.5"
"typescript": "^6.0.3",
"vitest": "^4.1.9"
},
"pnpm": {
"onlyBuiltDependencies": [
+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
+49 -15
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,18 +76,42 @@ 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">
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value)
if (!e.target.value) {
setResults(null)
setSearchError(null)
}
}}
placeholder="Title or author…"
className="flex-1"
/>
<div className="relative flex flex-1 items-center">
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value)
if (!e.target.value) {
setResults(null)
setSearchError(null)
}
}}
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" />
+7 -9
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
src={result.coverUrl}
alt={result.title}
className="h-20 w-14 shrink-0 rounded object-cover"
/>
) : (
<div className="h-20 w-14 shrink-0 rounded bg-muted" />
)}
<CoverViewer
src={result.coverUrl}
alt={result.title}
className="h-20 w-14 rounded object-cover"
placeholderClassName="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" {