Compare commits
22
Commits
v2026.05.29.7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cc6295226 | ||
|
|
4d75660de8
|
||
|
|
08a61569f4
|
||
|
|
7a0fc1b801 | ||
|
|
1f4bb72b9c | ||
|
|
112cd7a404 | ||
|
|
520e55563d
|
||
|
|
989ad65046 | ||
|
|
813a189745
|
||
|
|
0055e1cf0d
|
||
|
|
6c1213cb83
|
||
|
|
908dd155a0 | ||
|
|
c368ffd5e8 | ||
|
|
bae16f0ef3 | ||
|
|
5c7848276f | ||
|
|
ede3ae957f | ||
|
|
b95ac5e07a | ||
|
|
36cead4630 | ||
|
|
3778237f68 | ||
|
|
26b106a37a | ||
|
|
46bc53c5f0 | ||
|
|
575a93b945 |
-100
@@ -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
|
||||
```
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Bookshelf
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](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 |
|
||||
|
||||
@@ -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
@@ -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 .
|
||||
|
||||
@@ -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
@@ -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) => ({
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
setup:
|
||||
project: books
|
||||
config: dev_secrets
|
||||
Generated
+1742
-1686
File diff suppressed because it is too large
Load Diff
+23
-23
@@ -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": [
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user