Compare commits

..

1 Commits

Author SHA1 Message Date
gib 693a10957c init commit 2025-10-28 10:57:53 -05:00
246 changed files with 17717 additions and 46844 deletions
+676
View File
@@ -0,0 +1,676 @@
---
description: Guidelines and best practices for building Convex projects, including database schema design, queries, mutations, and real-world examples
globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx
---
# Convex guidelines
## Function guidelines
### New function syntax
- ALWAYS use the new function syntax for Convex functions. For example:
```typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
export const f = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
// Function body
},
});
```
### Http endpoint syntax
- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
```typescript
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/echo",
method: "POST",
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
}),
});
```
- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
### Validators
- Below is an example of an array validator:
```typescript
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
simpleArray: v.array(v.union(v.string(), v.number())),
},
handler: async (ctx, args) => {
//...
},
});
```
- Below is an example of a schema with validators that codify a discriminated union type:
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
results: defineTable(
v.union(
v.object({
kind: v.literal("error"),
errorMessage: v.string(),
}),
v.object({
kind: v.literal("success"),
value: v.number(),
}),
),
)
});
```
- Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value:
```typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
export const exampleQuery = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
console.log("This query returns a null value");
return null;
},
});
```
- Here are the valid Convex types along with their respective validators:
Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Id | string | `doc._id` | `v.id(tableName)` | |
| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
| Boolean | boolean | `true` | `v.boolean()` |
| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". |
### Function registration
- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
- You CANNOT register a function through the `api` or `internal` objects.
- ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator.
- If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`.
### Function calling
- Use `ctx.runQuery` to call a query from a query, mutation, or action.
- Use `ctx.runMutation` to call a mutation from a mutation or action.
- Use `ctx.runAction` to call an action from an action.
- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
```
export const f = query({
args: { name: v.string() },
returns: v.string(),
handler: async (ctx, args) => {
return "Hello " + args.name;
},
});
export const g = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
return null;
},
});
```
### Function references
- Function references are pointers to registered Convex functions.
- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
### Api design
- Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory.
- Use `query`, `mutation`, and `action` to define public functions.
- Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions.
### Pagination
- Paginated queries are queries that return a list of results in incremental pages.
- You can define pagination using the following syntax:
```ts
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
export const listWithExtraArg = query({
args: { paginationOpts: paginationOptsValidator, author: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.filter((q) => q.eq(q.field("author"), args.author))
.order("desc")
.paginate(args.paginationOpts);
},
});
```
Note: `paginationOpts` is an object with the following properties:
- `numItems`: the maximum number of documents to return (the validator is `v.number()`)
- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
- A query that ends in `.paginate()` returns an object that has the following properties:
- page (contains an array of documents that you fetches)
- isDone (a boolean that represents whether or not this is the last page of documents)
- continueCursor (a string that represents the cursor to use to fetch the next page of documents)
## Validator guidelines
- `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead.
- Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported.
## Schema guidelines
- Always define your schema in `convex/schema.ts`.
- Always import the schema definition functions from `convex/server`:
- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`.
- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2".
- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes.
## Typescript guidelines
- You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table.
- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record<Id<'users'>, string>`. Below is an example of using `Record` with an `Id` type in a query:
```ts
import { query } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
export const exampleQuery = query({
args: { userIds: v.array(v.id("users")) },
returns: v.record(v.id("users"), v.string()),
handler: async (ctx, args) => {
const idToUsername: Record<Id<"users">, string> = {};
for (const userId of args.userIds) {
const user = await ctx.db.get(userId);
if (user) {
idToUsername[user._id] = user.username;
}
}
return idToUsername;
},
});
```
- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`.
- Always use `as const` for string literals in discriminated union types.
- When using the `Array` type, make sure to always define your arrays as `const array: Array<T> = [...];`
- When using the `Record` type, make sure to always define your records as `const record: Record<KeyType, ValueType> = {...};`
- Always add `@types/node` to your `package.json` when using any Node.js built-in modules.
## Full text search guidelines
- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:
const messages = await ctx.db
.query("messages")
.withSearchIndex("search_body", (q) =>
q.search("body", "hello hi").eq("channel", "#general"),
)
.take(10);
## Query guidelines
- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.
- Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result.
- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
### Ordering
- By default Convex always returns documents in ascending `_creationTime` order.
- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
## Mutation guidelines
- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist.
- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist.
## Action guidelines
- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
- Never use `ctx.db` inside of an action. Actions don't have access to the database.
- Below is an example of the syntax for an action:
```ts
import { action } from "./_generated/server";
export const exampleAction = action({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
console.log("This action does not return anything");
return null;
},
});
```
## Scheduling guidelines
### Cron guidelines
- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.
- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.
- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,
```ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
import { internalAction } from "./_generated/server";
const empty = internalAction({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
console.log("empty");
},
});
const crons = cronJobs();
// Run `internal.crons.empty` every two hours.
crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {});
export default crons;
```
- You can register Convex functions within `crons.ts` just like any other file.
- If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file.
## File storage guidelines
- Convex includes file storage for large files like images, videos, and PDFs.
- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.
- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.
Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`.
```
import { query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
type FileMetadata = {
_id: Id<"_storage">;
_creationTime: number;
contentType?: string;
sha256: string;
size: number;
}
export const exampleQuery = query({
args: { fileId: v.id("_storage") },
returns: v.null(),
handler: async (ctx, args) => {
const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId);
console.log(metadata);
return null;
},
});
```
- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.
# Examples:
## Example: chat-app
### Task
```
Create a real-time chat application backend with AI responses. The app should:
- Allow creating users with names
- Support multiple chat channels
- Enable users to send messages to channels
- Automatically generate AI responses to user messages
- Show recent message history
The backend should provide APIs for:
1. User management (creation)
2. Channel management (creation)
3. Message operations (sending, listing)
4. AI response generation using OpenAI's GPT-4
Messages should be stored with their channel, author, and content. The system should maintain message order
and limit history display to the 10 most recent messages per channel.
```
### Analysis
1. Task Requirements Summary:
- Build a real-time chat backend with AI integration
- Support user creation
- Enable channel-based conversations
- Store and retrieve messages with proper ordering
- Generate AI responses automatically
2. Main Components Needed:
- Database tables: users, channels, messages
- Public APIs for user/channel management
- Message handling functions
- Internal AI response generation system
- Context loading for AI responses
3. Public API and Internal Functions Design:
Public Mutations:
- createUser:
- file path: convex/index.ts
- arguments: {name: v.string()}
- returns: v.object({userId: v.id("users")})
- purpose: Create a new user with a given name
- createChannel:
- file path: convex/index.ts
- arguments: {name: v.string()}
- returns: v.object({channelId: v.id("channels")})
- purpose: Create a new channel with a given name
- sendMessage:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()}
- returns: v.null()
- purpose: Send a message to a channel and schedule a response from the AI
Public Queries:
- listMessages:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels")}
- returns: v.array(v.object({
_id: v.id("messages"),
_creationTime: v.number(),
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}))
- purpose: List the 10 most recent messages from a channel in descending creation order
Internal Functions:
- generateResponse:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels")}
- returns: v.null()
- purpose: Generate a response from the AI for a given channel
- loadContext:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels")}
- returns: v.array(v.object({
_id: v.id("messages"),
_creationTime: v.number(),
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}))
- writeAgentResponse:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels"), content: v.string()}
- returns: v.null()
- purpose: Write an AI response to a given channel
4. Schema Design:
- users
- validator: { name: v.string() }
- indexes: <none>
- channels
- validator: { name: v.string() }
- indexes: <none>
- messages
- validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() }
- indexes
- by_channel: ["channelId"]
5. Background Processing:
- AI response generation runs asynchronously after each user message
- Uses OpenAI's GPT-4 to generate contextual responses
- Maintains conversation context using recent message history
### Implementation
#### package.json
```typescript
{
"name": "chat-app",
"description": "This example shows how to build a chat app without authentication.",
"version": "1.0.0",
"dependencies": {
"convex": "^1.17.4",
"openai": "^4.79.0"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}
```
#### tsconfig.json
```typescript
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
},
"exclude": ["convex"],
"include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"]
}
```
#### convex/index.ts
```typescript
import {
query,
mutation,
internalQuery,
internalMutation,
internalAction,
} from "./_generated/server";
import { v } from "convex/values";
import OpenAI from "openai";
import { internal } from "./_generated/api";
/**
* Create a user with a given name.
*/
export const createUser = mutation({
args: {
name: v.string(),
},
returns: v.id("users"),
handler: async (ctx, args) => {
return await ctx.db.insert("users", { name: args.name });
},
});
/**
* Create a channel with a given name.
*/
export const createChannel = mutation({
args: {
name: v.string(),
},
returns: v.id("channels"),
handler: async (ctx, args) => {
return await ctx.db.insert("channels", { name: args.name });
},
});
/**
* List the 10 most recent messages from a channel in descending creation order.
*/
export const listMessages = query({
args: {
channelId: v.id("channels"),
},
returns: v.array(
v.object({
_id: v.id("messages"),
_creationTime: v.number(),
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}),
),
handler: async (ctx, args) => {
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(10);
return messages;
},
});
/**
* Send a message to a channel and schedule a response from the AI.
*/
export const sendMessage = mutation({
args: {
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.channelId);
if (!channel) {
throw new Error("Channel not found");
}
const user = await ctx.db.get(args.authorId);
if (!user) {
throw new Error("User not found");
}
await ctx.db.insert("messages", {
channelId: args.channelId,
authorId: args.authorId,
content: args.content,
});
await ctx.scheduler.runAfter(0, internal.index.generateResponse, {
channelId: args.channelId,
});
return null;
},
});
const openai = new OpenAI();
export const generateResponse = internalAction({
args: {
channelId: v.id("channels"),
},
returns: v.null(),
handler: async (ctx, args) => {
const context = await ctx.runQuery(internal.index.loadContext, {
channelId: args.channelId,
});
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: context,
});
const content = response.choices[0].message.content;
if (!content) {
throw new Error("No content in response");
}
await ctx.runMutation(internal.index.writeAgentResponse, {
channelId: args.channelId,
content,
});
return null;
},
});
export const loadContext = internalQuery({
args: {
channelId: v.id("channels"),
},
returns: v.array(
v.object({
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
}),
),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.channelId);
if (!channel) {
throw new Error("Channel not found");
}
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(10);
const result = [];
for (const message of messages) {
if (message.authorId) {
const user = await ctx.db.get(message.authorId);
if (!user) {
throw new Error("User not found");
}
result.push({
role: "user" as const,
content: `${user.name}: ${message.content}`,
});
} else {
result.push({ role: "assistant" as const, content: message.content });
}
}
return result;
},
});
export const writeAgentResponse = internalMutation({
args: {
channelId: v.id("channels"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert("messages", {
channelId: args.channelId,
content: args.content,
});
return null;
},
});
```
#### convex/schema.ts
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
channels: defineTable({
name: v.string(),
}),
users: defineTable({
name: v.string(),
}),
messages: defineTable({
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}).index("by_channel", ["channelId"]),
});
```
#### src/App.tsx
```typescript
export default function App() {
return <div>Hello World</div>;
}
```
-49
View File
@@ -1,49 +0,0 @@
# Dependencies - MUST exclude these
node_modules
**/node_modules
.pnp
.pnp.js
# Turbo
.turbo
**/.turbo
# Next.js build artifacts
.next
**/.next
out
**/out
# Development
.git
.gitignore
*.log
#.env
#.env.*
!.env.example
.vscode
.idea
# Tests
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
# Build artifacts
dist
**/dist
build
**/build
# Convex local
packages/backend/.convex
# OS
.DS_Store
Thumbs.db
# Docker
docker
Dockerfile
.dockerignore
+14 -17
View File
@@ -2,25 +2,22 @@
# Keep this file up-to-date when you add new variables to \`.env\`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
## Next.js ##
NODE_ENV=
SENTRY_AUTH_TOKEN=
NEXT_PUBLIC_SITE_URL=https://example.com
NEXT_PUBLIC_CONVEX_URL=https://api.convex.example.com # convex-backend:3210
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.example.com
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_URL=https://sentry.example.com
NEXT_PUBLIC_SENTRY_ORG=sentry
NEXT_PUBLIC_SENTRY_PROJECT_NAME=example
## Convex ##
CONVEX_SELF_HOSTED_URL=https://api.convex.example.com # convex-backend:3210
CONVEX_SELF_HOSTED_ADMIN_KEY= # Generate after hosted on docker
# Convex Auth
CONVEX_SITE_URL=http://localhost:3000 # Always localhost:3000 for local dev; update in Convex Dashboard for production
CI=
SKIP_ENV_VALIDATION=
CONVEX_SELF_HOSTED_URL=
CONVEX_SELF_HOSTED_ADMIN_KEY=
CONVEX_AUTH_URL=
SITE_URL=
USESEND_API_KEY=
USESEND_URL=https://usesend.example.com
USESEND_FROM_EMAIL=My App <noreply@example.com>
AUTH_AUTHENTIK_ID=
AUTH_AUTHENTIK_SECRET=
AUTH_AUTHENTIK_ISSUER=
AUTH_MICROSOFT_ENTRA_ID_ID=
AUTH_MICROSOFT_ENTRA_ID_SECRET=
AUTH_MICROSOFT_ENTRA_ID_ISSUER=
AUTH_MICROSOFT_ENTRA_ID_AUTH_UR=
SENTRY_AUTH_TOKEN=
SENTRY_DSN=
SENTRY_ORG=
SENTRY_PROJECT_NAME=
-3
View File
@@ -1,8 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Hosting
/docker/data
# dependencies
node_modules
.pnp
+3
View File
@@ -0,0 +1,3 @@
{
"arrowParens": "always"
}
-1411
View File
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Convex, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+169 -435
View File
@@ -1,456 +1,190 @@
# Convex Turbo Monorepo
# Fullstack monorepo template feat. Expo, Turbo, Next.js, Convex, Clerk
A production-ready Turborepo starter with Next.js, Expo, and self-hosted Convex backend. Built with TypeScript, Tailwind CSS, and modern tooling.
This is a modern TypeScript monorepo template with AI web and native apps
featuring:
---
- Turborepo: Monorepo management
- React 19: Latest React with concurrent features
- Next.js 15: Web app & marketing page with App Router
- Tailwind CSS v4: Modern CSS-first configuration
- React Native [Expo](https://expo.dev/): Mobile/native app with New Architecture
- [Convex](https://convex.dev): Backend, database, server functions
- [Clerk](https://clerk.dev): User authentication
- OpenAI: Text summarization (optional)
## What's Inside?
The example app is a note taking app that can summarize notes using AI. Features
include:
### Apps & Packages
- Marketing page
- Dashboard page (web & native)
- Note taking page (web & native)
- Backend API that serves web & native with the same API
- Relational database
- End to end type safety (schema definition to frontend API clients)
- User authentication
- Asynchronous call to an OpenAI
- Everything is realtime by default
- **`apps/next`** - Next.js 16 web application with App Router
- **`apps/expo`** - Expo 54 mobile application _(in progress)_
- **`@gib/backend`** - Self-hosted Convex backend with authentication
- **`@gib/ui`** - Shared shadcn/ui component library
- **`@gib/eslint-config`** - ESLint configuration
- **`@gib/prettier-config`** - Prettier configuration with import sorting
- **`@gib/tailwind-config`** - Tailwind CSS v4 configuration
- **`@gib/tsconfig`** - Shared TypeScript configurations
## Using this example
### Tech Stack
### 1. Install dependencies
- **Framework:** Next.js 16 (App Router) + Expo 54
- **Backend:** Convex (self-hosted)
- **Auth:** @convex-dev/auth with Authentik OAuth & Password providers
- **Styling:** Tailwind CSS v4 + shadcn/ui
- **Language:** TypeScript (strict mode)
- **Package Manager:** Bun
- **Monorepo:** Turborepo
- **Deployment:** Docker (standalone)
If you don't have `yarn` installed, run `npm install --global yarn`.
---
Run `yarn`.
## Getting Started
### 2. Configure Convex
This is a self-hosted template. The full setup requires a server (home server or VPS)
to host the Convex backend and dashboard, and a reverse proxy (nginx-proxy-manager is
recommended) to expose them over HTTPS. The Next.js app can run locally in dev mode
once the Convex containers are reachable.
> Note: The following command will print an error and ask you to add the
> appropriate environment variable to proceed. Continue reading on for how to do
> that.
### Prerequisites
- [Bun](https://bun.sh) (v1.2+)
- [Docker](https://www.docker.com/) & Docker Compose (for self-hosted Convex)
- Node.js 22+ (for compatibility)
- A running nginx-proxy-manager instance (or similar reverse proxy) to expose Convex over HTTPS
---
### Step 1 — Clone & Install
```bash
git clone https://git.gbrown.org/gib/convex-monorepo
cd convex-monorepo
bun install
```sh
npm run setup --workspace packages/backend
```
If you're using this as a template for a new project, remove the existing remote and
add your own:
The script will log you into Convex if you aren't already and prompt you to
create a project (free). It will then wait to deploy your code until you set the
environment variables in the dashboard.
```bash
git remote remove origin
git remote add origin https://your-git-host.com/your/new-repo.git
Configure Clerk with [this guide](https://docs.convex.dev/auth/clerk). Then add
the `CLERK_ISSUER_URL` found in the "convex" template
[here](https://dashboard.clerk.com/last-active?path=jwt-templates), to your
Convex environment variables
[here](https://dashboard.convex.dev/deployment/settings/environment-variables&var=CLERK_ISSUER_URL).
Make sure to enable **Google and Apple** as possible Social Connection
providers, as these are used by the React Native login implementation.
After that, optionally add the `OPENAI_API_KEY` env var from
[OpenAI](https://platform.openai.com/account/api-keys) to your Convex
environment variables to get AI summaries.
The `setup` command should now finish successfully.
### 3. Configure both apps
In each app directory (`apps/web`, `apps/native`) create a `.env.local` file
using the `.example.env` as a template and fill out your Convex and Clerk
environment variables.
- Use the `CONVEX_URL` from `packages/backend/.env.local` for
`{NEXT,EXPO}_PUBLIC_CONVEX_URL`.
- The Clerk publishable & secret keys can be found
[here](https://dashboard.clerk.com/last-active?path=api-keys).
### 4. Run both apps
Run the following command to run both the web and mobile apps:
```sh
npm run dev
```
---
This will allow you to use the ⬆ and ⬇ keyboard keys to see logs for each
of the Convex backend, web app, and mobile app separately.
If you'd rather see all of the logs in one place, delete the
`"ui": "tui",` line in [turbo.json](./turbo.json).
### Step 2 — Configure the Docker Environment
## Deploying
The `docker/` directory contains everything needed to run the Convex backend and the
Next.js app in production.
In order to both deploy the frontend and Convex, run this as the build command from the apps/web directory:
```bash
cd docker/
cp .env.example .env
```sh
cd ../../packages/backend && npx convex deploy --cmd 'cd ../../apps/web && turbo run build' --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL
```
Edit `docker/.env` and fill in your values. The variables below the Next.js app section
control the Convex containers — you'll need to choose:
- `INSTANCE_NAME` — a unique name for your Convex instance
- `INSTANCE_SECRET` — a secret string (generate something random)
- `CONVEX_CLOUD_ORIGIN` — the public HTTPS URL for your Convex backend API (e.g. `https://api.convex.example.com`)
- `CONVEX_SITE_ORIGIN` — the public HTTPS URL for Convex Auth HTTP routes (e.g. `https://api.convex.example.com`)
- `NEXT_PUBLIC_DEPLOYMENT_URL` — the URL for the Convex dashboard (e.g. `https://dashboard.convex.example.com`)
---
### Step 3 — Start the Convex Containers
```bash
cd docker/
sudo docker compose up -d convex-backend convex-dashboard
```
Wait a moment for `convex-backend` to pass its health check, then verify both
containers are running:
```bash
sudo docker compose ps
```
Reverse-proxy the two Convex services through nginx-proxy-manager (or your preferred
proxy) to the URLs you chose in Step 2. Both must be reachable over HTTPS before you
can proceed.
---
### Step 4 — Generate the Convex Admin Key
With the backend container running, generate the admin key:
```bash
cd docker/
./generate_convex_admin_key
```
Copy the printed key — you'll need it as `CONVEX_SELF_HOSTED_ADMIN_KEY` in the root
`.env` file.
---
### Step 5 — Configure Root Environment Variables
Create the root `.env` file:
```bash
# From the repo root
cp .env.example .env
```
Fill out all values in `/.env`:
```bash
# Next.js
NODE_ENV=development
SENTRY_AUTH_TOKEN= # From your self-hosted Sentry
NEXT_PUBLIC_SITE_URL=https://example.com
NEXT_PUBLIC_CONVEX_URL=https://api.convex.example.com
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.example.com
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_URL=https://sentry.example.com
NEXT_PUBLIC_SENTRY_ORG=sentry
NEXT_PUBLIC_SENTRY_PROJECT_NAME=my-project
# Convex
CONVEX_SELF_HOSTED_URL=https://api.convex.example.com
CONVEX_SELF_HOSTED_ADMIN_KEY= # From Step 4
CONVEX_SITE_URL=http://localhost:3000 # Always localhost:3000 for local dev
# Auth (synced to Convex in Step 6)
USESEND_API_KEY=
USESEND_URL=https://usesend.example.com
USESEND_FROM_EMAIL=My App <noreply@example.com>
AUTH_AUTHENTIK_ID=
AUTH_AUTHENTIK_SECRET=
AUTH_AUTHENTIK_ISSUER=https://auth.example.com/application/o/my-app/
```
---
### Step 6 — Generate JWT Keys & Sync Environment Variables to Convex
Generate the RS256 JWT keypair needed for Convex Auth:
```bash
cd packages/backend
bun run scripts/generateKeys.mjs
```
This prints `JWT_PRIVATE_KEY` and `JWKS` values. Sync them to your Convex deployment
along with all other backend environment variables:
```bash
# From packages/backend/
bun with-env npx convex env set JWT_PRIVATE_KEY "your-private-key"
bun with-env npx convex env set JWKS "your-jwks"
bun with-env npx convex env set AUTH_AUTHENTIK_ID "your-client-id"
bun with-env npx convex env set AUTH_AUTHENTIK_SECRET "your-client-secret"
bun with-env npx convex env set AUTH_AUTHENTIK_ISSUER "your-issuer-url"
bun with-env npx convex env set USESEND_API_KEY "your-api-key"
bun with-env npx convex env set USESEND_URL "https://usesend.example.com"
bun with-env npx convex env set USESEND_FROM_EMAIL "My App <noreply@example.com>"
```
**For production auth to work**, you must also update `CONVEX_SITE_URL` in the Convex
Dashboard to your production Next.js URL. Go to
`https://dashboard.convex.example.com` → Settings → Environment Variables and set:
```
CONVEX_SITE_URL = https://example.com
```
The root `.env` value of `http://localhost:3000` is correct for local dev and should
not be changed — only update it in the Dashboard for production.
---
### Step 7 — Start the Development Server
```bash
# From project root
bun dev:next # Next.js app + Convex backend (most common)
# or
bun dev # All apps (Next.js + Expo + Backend)
```
**App URLs:**
- **Next.js:** http://localhost:3000
- **Convex Dashboard:** https://dashboard.convex.example.com
---
## Development Commands
```bash
# Development
bun dev # Run all apps
bun dev:next # Next.js + Convex backend
bun dev:expo # Expo + Convex backend
bun dev:backend # Convex backend only
# Quality Checks
bun typecheck # Type checking (recommended for testing)
bun lint # Lint all packages
bun lint:fix # Lint and auto-fix
bun format # Check formatting
bun format:fix # Fix formatting
# Build
bun build # Build all packages (production)
# Utilities
bun ui-add # Add shadcn/ui components
bun clean # Clean node_modules
bun clean:ws # Clean workspace caches
```
### Single Package Commands
Use Turborepo filters to target specific packages:
```bash
bun turbo run dev -F @gib/next # Run Next.js only
bun turbo run typecheck -F @gib/backend # Typecheck backend
bun turbo run lint -F @gib/ui # Lint UI package
```
---
## Project Structure
```
convex-monorepo/
├── apps/
│ ├── next/ # Next.js web app
│ │ ├── src/
│ │ │ ├── app/ # App Router pages
│ │ │ ├── components/ # React components
│ │ │ └── lib/ # Utilities
│ │ └── package.json
│ └── expo/ # Expo mobile app (WIP)
├── packages/
│ ├── backend/ # Convex backend
│ │ ├── convex/ # Convex functions (synced to deployment)
│ │ ├── scripts/ # Utilities (generateKeys.mjs)
│ │ └── types/ # Shared types
│ └── ui/ # shadcn/ui components
│ └── src/ # Component source files
├── tools/ # Shared configurations
│ ├── eslint/
│ ├── prettier/
│ ├── tailwind/
│ └── typescript/
├── docker/ # Self-hosted deployment
│ ├── compose.yml
│ ├── Dockerfile
│ └── .env.example
├── turbo.json # Turborepo configuration
└── package.json # Root workspace & catalogs
```
---
## Features
### Authentication
- **OAuth:** Authentik SSO integration
- **Password:** Custom password auth with email verification
- **OTP:** Email verification via self-hosted UseSend
- **Session Management:** Secure cookie-based sessions (30-day max age)
### Next.js App
- **App Router:** Next.js 16 with React Server Components
- **Data Preloading:** SSR data fetching with `preloadQuery` + `usePreloadedQuery`
- **Middleware:** Route protection & IP-based security (`src/proxy.ts`)
- **Styling:** Tailwind CSS v4 with dark mode (OKLCH-based theme)
- **Analytics:** Plausible (privacy-focused, proxied through Next.js)
- **Monitoring:** Sentry error tracking & performance
### Backend
- **Real-time:** Convex reactive queries
- **File Storage:** Built-in file upload/storage
- **Auth:** Multi-provider authentication
- **Type-safe:** Full TypeScript with generated types
- **Self-hosted:** Complete control over your data
### Developer Experience
- **Monorepo:** Turborepo for efficient builds and caching
- **Type Safety:** Strict TypeScript throughout
- **Code Quality:** ESLint + Prettier with auto-fix
- **Hot Reload:** Fast refresh for all packages
- **Catalog Deps:** Centralized dependency version management
---
## Deployment
### Production Deployment (Docker)
Once the Convex containers are running (they only need to be started once), deploying
a new version of the Next.js app is a two-command workflow:
```bash
# SSH onto your server, then:
cd docker/
# Build the new image
sudo docker compose build next-app
# Deploy it
sudo docker compose up -d next-app
```
To start all services from scratch:
```bash
cd docker/
sudo docker compose up -d convex-backend convex-dashboard
# Wait for backend health check to pass, then:
sudo docker compose up -d next-app
```
**Services:**
- `next-app` — Next.js standalone build
- `convex-backend` — Convex backend (port 3210)
- `convex-dashboard` — Admin dashboard (port 6791)
**Network:** Uses `nginx-bridge` Docker network (reverse proxy via nginx-proxy-manager).
### Production Checklist
- [ ] Fill out `docker/.env` with your domain names and secrets
- [ ] Start `convex-backend` and `convex-dashboard` containers
- [ ] Generate and set `CONVEX_SELF_HOSTED_ADMIN_KEY` via `./generate_convex_admin_key`
- [ ] Reverse-proxy both Convex services via nginx-proxy-manager with SSL
- [ ] Fill out root `/.env` with all environment variables
- [ ] Generate JWT keys and sync all env vars to Convex (`bun with-env npx convex env set ...`)
- [ ] Update `CONVEX_SITE_URL` in the Convex Dashboard to your production Next.js URL
- [ ] Build and start the `next-app` container
- [ ] Back up `docker/data/` regularly (contains all Convex database data)
---
## Documentation
- **[AGENTS.md](./AGENTS.md)** — Comprehensive guide for AI agents & developers
- **[Convex Docs](https://docs.convex.dev)** — Official Convex documentation
- **[Turborepo Docs](https://turbo.build/repo/docs)** — Turborepo documentation
- **[Next.js Docs](https://nextjs.org/docs)** — Next.js documentation
---
## Troubleshooting
### Backend typecheck shows TypeScript help message
This is expected behavior. The backend package follows Convex's structure with only
`convex/tsconfig.json` (no root tsconfig). Running `bun typecheck` from the repo root
will show TypeScript's help text for `@gib/backend` — this is not an error.
### Imports from Convex require `.js` extension
The project uses ESM (`"type": "module"`), which requires explicit file extensions:
```typescript
// ✅ Correct
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
// ❌ Wrong — will fail at runtime
import { api } from '@gib/backend/convex/_generated/api';
import { api } from '@gib/backend/convex/_generated/api.js';
```
### Docker containers won't start
1. Check Docker logs: `sudo docker compose logs`
2. Verify environment variables in `docker/.env`
3. Ensure the `nginx-bridge` network exists: `sudo docker network create nginx-bridge`
4. Check that the required ports (3210, 6791) are not already in use
### Auth doesn't work in production
Make sure `CONVEX_SITE_URL` is set to your production Next.js URL in the **Convex
Dashboard** (not just in the root `.env` file). The root `.env` should always contain
`http://localhost:3000`; the Dashboard must have your production URL.
### Catalog updates break workspace
After updating dependencies, if you see `sherif` errors on `bun install`:
1. Never use `bun update` inside individual package directories
2. Edit the version in root `package.json` catalog section instead
3. Run `bun install` from the root
4. Verify with `bun lint:ws`
---
## Contributing
This is a personal monorepo template. Feel free to fork and adapt for your needs.
### Code Style
- Single quotes, trailing commas
- 80 character line width
- ESLint + Prettier enforced
- `const fn = () => {}` over `function fn()` (strong preference)
- Import order: Types → React → Next → Third-party → @gib → Local
Run `bun lint:fix` and `bun format:fix` before committing.
---
## License
MIT
---
## Acknowledgments
Built with inspiration from:
- [T3 Turbo](https://github.com/t3-oss/create-t3-turbo)
- [Convex](https://convex.dev)
- [shadcn/ui](https://ui.shadcn.com)
- [Turborepo](https://turbo.build)
There is a vercel.json file in the apps/web directory with this configuration for Vercel.
## What's inside?
This monorepo template includes the following packages/apps:
### Apps and Packages
- `web`: a [Next.js 15](https://nextjs.org/) app with Tailwind CSS and Clerk
- `native`: a [React Native](https://reactnative.dev/) app built with
[expo](https://docs.expo.dev/)
- `packages/backend`: a [Convex](https://www.convex.dev/) folder with the
database schema and shared functions
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
To install a new package, `cd` into that directory, such as [packages/backend](./packages/backend/), and then run `yarn add mypackage@latest`
### Utilities
This Turborepo has some additional tools already setup for you:
- [Expo](https://docs.expo.dev/) for native development
- [TypeScript](https://www.typescriptlang.org/) for static type checking
- [Prettier](https://prettier.io) for code formatting
# What is Convex?
[Convex](https://convex.dev) is a hosted backend platform with a built-in
reactive database that lets you write your
[database schema](https://docs.convex.dev/database/schemas) and
[server functions](https://docs.convex.dev/functions) in
[TypeScript](https://docs.convex.dev/typescript). Server-side database
[queries](https://docs.convex.dev/functions/query-functions) automatically
[cache](https://docs.convex.dev/functions/query-functions#caching--reactivity)
and [subscribe](https://docs.convex.dev/client/react#reactivity) to data,
powering a
[realtime `useQuery` hook](https://docs.convex.dev/client/react#fetching-data)
in our [React client](https://docs.convex.dev/client/react). There are also
clients for [Python](https://docs.convex.dev/client/python),
[Rust](https://docs.convex.dev/client/rust),
[ReactNative](https://docs.convex.dev/client/react-native), and
[Node](https://docs.convex.dev/client/javascript), as well as a straightforward
[HTTP API](https://github.com/get-convex/convex-js/blob/main/src/browser/http_client.ts#L40).
The database supports
[NoSQL-style documents](https://docs.convex.dev/database/document-storage) with
[relationships](https://docs.convex.dev/database/document-ids) and
[custom indexes](https://docs.convex.dev/database/indexes/) (including on fields
in nested objects).
The [`query`](https://docs.convex.dev/functions/query-functions) and
[`mutation`](https://docs.convex.dev/functions/mutation-functions) server
functions have transactional, low latency access to the database and leverage
our [`v8` runtime](https://docs.convex.dev/functions/runtimes) with
[determinism guardrails](https://docs.convex.dev/functions/runtimes#using-randomness-and-time-in-queries-and-mutations)
to provide the strongest ACID guarantees on the market: immediate consistency,
serializable isolation, and automatic conflict resolution via
[optimistic multi-version concurrency control](https://docs.convex.dev/database/advanced/occ)
(OCC / MVCC).
The [`action` server functions](https://docs.convex.dev/functions/actions) have
access to external APIs and enable other side-effects and non-determinism in
either our [optimized `v8` runtime](https://docs.convex.dev/functions/runtimes)
or a more
[flexible `node` runtime](https://docs.convex.dev/functions/runtimes#nodejs-runtime).
Functions can run in the background via
[scheduling](https://docs.convex.dev/scheduling/scheduled-functions) and
[cron jobs](https://docs.convex.dev/scheduling/cron-jobs).
Development is cloud-first, with
[hot reloads for server function](https://docs.convex.dev/cli#run-the-convex-dev-server)
editing via the [CLI](https://docs.convex.dev/cli). There is a
[dashboard UI](https://docs.convex.dev/dashboard) to
[browse and edit data](https://docs.convex.dev/dashboard/deployments/data),
[edit environment variables](https://docs.convex.dev/production/environment-variables),
[view logs](https://docs.convex.dev/dashboard/deployments/logs),
[run server functions](https://docs.convex.dev/dashboard/deployments/functions),
and more.
There are built-in features for
[reactive pagination](https://docs.convex.dev/database/pagination),
[file storage](https://docs.convex.dev/file-storage),
[reactive search](https://docs.convex.dev/text-search),
[https endpoints](https://docs.convex.dev/functions/http-actions) (for
webhooks),
[streaming import/export](https://docs.convex.dev/database/import-export/), and
[runtime data validation](https://docs.convex.dev/database/schemas#validators)
for [function arguments](https://docs.convex.dev/functions/args-validation) and
[database data](https://docs.convex.dev/database/schemas#schema-validation).
Everything scales automatically, and its
[free to start](https://www.convex.dev/plans).
+1 -1
View File
@@ -1 +1 @@
[["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21"],{"key":"22","value":"23"},{"key":"24","value":"25"},{"key":"26","value":"27"},{"key":"28","value":"29"},{"key":"30","value":"31"},{"key":"32","value":"33"},{"key":"34","value":"35"},{"key":"36","value":"37"},{"key":"38","value":"39"},{"key":"40","value":"41"},{"key":"42","value":"43"},{"key":"44","value":"45"},{"key":"46","value":"47"},{"key":"48","value":"49"},{"key":"50","value":"51"},{"key":"52","value":"53"},{"key":"54","value":"55"},{"key":"56","value":"57"},{"key":"58","value":"59"},{"key":"60","value":"61"},{"key":"62","value":"63"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/index.tsx",{"size":1935,"mtime":1774546215689,"hash":"64","data":"65"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/index.ts",{"size":28,"mtime":1768155639000,"hash":"66","data":"67"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-dark.png",{"size":19633,"mtime":1766222924000,"hash":"68"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/postcss.config.js",{"size":65,"mtime":1774546126644,"hash":"69","data":"70"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/post/[id].tsx",{"size":678,"mtime":1774546226606,"hash":"71","data":"72"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/convex.ts",{"size":909,"mtime":1774546187217,"data":"73"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eas.json",{"size":566,"mtime":1774546138669,"hash":"74","data":"75"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/_layout.tsx",{"size":836,"mtime":1774546198917,"hash":"76","data":"77"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/styles.css",{"size":89,"mtime":1774546134256,"hash":"78","data":"79"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.cache/.prettiercache",{"size":4929,"mtime":1774544663692},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-light.png",{"size":19133,"mtime":1766222924000,"hash":"80"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/package.json",{"size":2229,"mtime":1774546163623,"hash":"81","data":"82"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/turbo.json",{"size":171,"mtime":1774031879321,"hash":"83","data":"84"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eslint.config.mts",{"size":274,"mtime":1774546113649,"hash":"85","data":"86"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/tsconfig.json",{"size":387,"mtime":1766228480000,"hash":"87","data":"88"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/metro.config.js",{"size":511,"mtime":1768155639000,"hash":"89","data":"90"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/nativewind-env.d.ts",{"size":246,"mtime":1766222924000,"hash":"91","data":"92"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/base-url.ts",{"size":880,"mtime":1768155639000,"hash":"93","data":"94"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.expo-shared/assets.json",{"size":155,"mtime":1766222924000,"hash":"95","data":"96"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/session-store.ts",{"size":272,"mtime":1768155639000,"hash":"97","data":"98"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/app.config.ts",{"size":1333,"mtime":1768155639000,"hash":"99","data":"100"},"73c235a66242df70b69394cce29d1ed3",{"hashOfOptions":"101"},"11cdbef6afa001cd39bc187041ca6865",{"hashOfOptions":"102"},"1e8ac0d261e95efb19d290ffcf70ce36","b7edffce093c4c84092cc93f3dc208ef",{"hashOfOptions":"103"},"ead19d73283f9d8e08b55c896c9fd570",{"hashOfOptions":"104"},{"hashOfOptions":"105"},"a3c1487f8318513ae7c156acc857fde2",{"hashOfOptions":"106"},"8e407b4b1b0c0bd9c862a00243344be3",{"hashOfOptions":"107"},"52a1d72379b952dd802f47e1865bd0da",{"hashOfOptions":"108"},"863da15dbd856008b7c24077ca746d91","d8763702c14cdc382dcfb84f6f9a068f",{"hashOfOptions":"109"},"c7d4dcf839dfeaa02e0407adfd5e47a6",{"hashOfOptions":"110"},"1c1710ce3de3ce02e8054cc3787c8579",{"hashOfOptions":"111"},"6937fb7370f1e17491df649888d6ecc9",{"hashOfOptions":"112"},"dbe97bcde588a81538bbcd6a9befdddd",{"hashOfOptions":"113"},"d4d589c153ac8b5e7bf0fb130a5b5a7d",{"hashOfOptions":"114"},"dd2007a211e323deabb3f7fa7d16313f",{"hashOfOptions":"115"},"0f7f54c7161b8403d3bc42d91f59cd91",{"hashOfOptions":"116"},"1bc3e15a40c117eecc51294886ea9b38",{"hashOfOptions":"117"},"4f49c6df7733f874fbe72b4e20b3092b",{"hashOfOptions":"118"},"3000879843","3103968608","384110377","141502567","1235541372","1050155876","2025343866","4147067111","4228440757","3451484829","4039211292","3318113268","2585374463","45764855","1418614640","2754339483","1950506033","3468872477"]
[["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21"],{"key":"22","value":"23"},{"key":"24","value":"25"},{"key":"26","value":"27"},{"key":"28","value":"29"},{"key":"30","value":"31"},{"key":"32","value":"33"},{"key":"34","value":"35"},{"key":"36","value":"37"},{"key":"38","value":"39"},{"key":"40","value":"41"},{"key":"42","value":"43"},{"key":"44","value":"45"},{"key":"46","value":"47"},{"key":"48","value":"49"},{"key":"50","value":"51"},{"key":"52","value":"53"},{"key":"54","value":"55"},{"key":"56","value":"57"},{"key":"58","value":"59"},{"key":"60","value":"61"},{"key":"62","value":"63"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/eslint.config.mts",{"size":276,"mtime":1761443987000,"hash":"64","data":"65"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/app/index.tsx",{"size":5019,"mtime":1761443987000,"hash":"66","data":"67"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/app.config.ts",{"size":1333,"mtime":1761443987000,"hash":"68","data":"69"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/package.json",{"size":1736,"mtime":1761665033217,"hash":"70","data":"71"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/app/_layout.tsx",{"size":927,"mtime":1761443987000,"hash":"72","data":"73"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/metro.config.js",{"size":511,"mtime":1761443987000,"hash":"74","data":"75"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/utils/auth.ts",{"size":398,"mtime":1761443987000,"hash":"76","data":"77"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/index.ts",{"size":28,"mtime":1761443987000,"hash":"78","data":"79"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/utils/api.tsx",{"size":1327,"mtime":1761443987000,"hash":"80","data":"81"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/utils/session-store.ts",{"size":272,"mtime":1761443987000,"hash":"82","data":"83"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/turbo.json",{"size":163,"mtime":1761443987000,"hash":"84","data":"85"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/assets/icon-light.png",{"size":19133,"mtime":1761443987000,"hash":"86"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/eas.json",{"size":567,"mtime":1761443987000,"hash":"87","data":"88"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/postcss.config.js",{"size":66,"mtime":1761443987000,"hash":"89","data":"90"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/app/post/[id].tsx",{"size":757,"mtime":1761443987000,"hash":"91","data":"92"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/assets/icon-dark.png",{"size":19633,"mtime":1761443987000,"hash":"93"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/styles.css",{"size":90,"mtime":1761443987000,"hash":"94","data":"95"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/tsconfig.json",{"size":388,"mtime":1761663711587,"hash":"96","data":"97"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/src/utils/base-url.ts",{"size":880,"mtime":1761443987000,"hash":"98","data":"99"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/.expo-shared/assets.json",{"size":155,"mtime":1761443987000,"hash":"100","data":"101"},"/home/gib/Documents/Code/monorepo/convex-monorepo/apps/expo/nativewind-env.d.ts",{"size":246,"mtime":1761443987000,"hash":"102","data":"103"},"3157b700ecbb4aa217059352507464a2",{"hashOfOptions":"104"},"2be11479779af34f59ec5003513b8e0b",{"hashOfOptions":"105"},"bd476a3950d078330d3e06a560e9f747",{"hashOfOptions":"106"},"f137cf230cf525ca8c2f0bdf233a6e3f",{"hashOfOptions":"107"},"518cd9c33ede87c649b01dd727bb00a0",{"hashOfOptions":"108"},"fbe52c661bc5cbe8d2f7c1058981b896",{"hashOfOptions":"109"},"9455acf80595f4373e11d98e5bc87d89",{"hashOfOptions":"110"},"cc3395d2be4587e21093428fdb6f69e6",{"hashOfOptions":"111"},"0adb5df94af971795b21eddb560c26b5",{"hashOfOptions":"112"},"691dcf997a384f03d1ba5c043c1a3405",{"hashOfOptions":"113"},"c7d4dcf839dfeaa02e0407adfd5e47a6",{"hashOfOptions":"114"},"863da15dbd856008b7c24077ca746d91","a3c1487f8318513ae7c156acc857fde2",{"hashOfOptions":"115"},"5080fb7f49a201ad809050ee57249d41",{"hashOfOptions":"116"},"28454ca5c544a35129bb829072b8dbc1",{"hashOfOptions":"117"},"1e8ac0d261e95efb19d290ffcf70ce36","5068090396cd9f4f9463f09f43c9e257",{"hashOfOptions":"118"},"74d22c2830502b877ac3b806a788bfa3",{"hashOfOptions":"119"},"29e520338914a80764ca0866a87fac3d",{"hashOfOptions":"120"},"0f7f54c7161b8403d3bc42d91f59cd91",{"hashOfOptions":"121"},"d4d589c153ac8b5e7bf0fb130a5b5a7d",{"hashOfOptions":"122"},"3529965326","1818025557","2842754191","1504153565","3815129580","3236408049","1664229517","2979851528","3930068749","2135093465","2923444549","366302476","4133218715","3075567833","3597942159","1044122342","1450990178","3667891651","3412980713"]
+23 -23
View File
@@ -1,32 +1,32 @@
import type { ConfigContext, ExpoConfig } from 'expo/config';
import type { ConfigContext, ExpoConfig } from "expo/config";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'expo',
slug: 'expo',
scheme: 'expo',
version: '0.1.0',
orientation: 'portrait',
icon: './assets/icon-light.png',
userInterfaceStyle: 'automatic',
name: "expo",
slug: "expo",
scheme: "expo",
version: "0.1.0",
orientation: "portrait",
icon: "./assets/icon-light.png",
userInterfaceStyle: "automatic",
updates: {
fallbackToCacheTimeout: 0,
},
newArchEnabled: true,
assetBundlePatterns: ['**/*'],
assetBundlePatterns: ["**/*"],
ios: {
bundleIdentifier: 'your.bundle.identifier',
bundleIdentifier: "your.bundle.identifier",
supportsTablet: true,
icon: {
light: './assets/icon-light.png',
dark: './assets/icon-dark.png',
light: "./assets/icon-light.png",
dark: "./assets/icon-dark.png",
},
},
android: {
package: 'your.bundle.identifier',
package: "your.bundle.identifier",
adaptiveIcon: {
foregroundImage: './assets/icon-light.png',
backgroundColor: '#1F104A',
foregroundImage: "./assets/icon-light.png",
backgroundColor: "#1F104A",
},
edgeToEdgeEnabled: true,
},
@@ -42,17 +42,17 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
reactCompiler: true,
},
plugins: [
'expo-router',
'expo-secure-store',
'expo-web-browser',
"expo-router",
"expo-secure-store",
"expo-web-browser",
[
'expo-splash-screen',
"expo-splash-screen",
{
backgroundColor: '#E4E4E7',
image: './assets/icon-light.png',
backgroundColor: "#E4E4E7",
image: "./assets/icon-light.png",
dark: {
backgroundColor: '#18181B',
image: './assets/icon-dark.png',
backgroundColor: "#18181B",
image: "./assets/icon-dark.png",
},
},
],
+1 -1
View File
@@ -6,7 +6,7 @@
"build": {
"base": {
"node": "22.12.0",
"bun": "1.3.10",
"pnpm": "9.15.4",
"ios": {
"resourceClass": "m-medium"
}
+4 -4
View File
@@ -1,11 +1,11 @@
import { defineConfig } from 'eslint/config';
import { defineConfig } from "eslint/config";
import { baseConfig } from '@gib/eslint-config/base';
import { reactConfig } from '@gib/eslint-config/react';
import { baseConfig } from "@acme/eslint-config/base";
import { reactConfig } from "@acme/eslint-config/react";
export default defineConfig(
{
ignores: ['.expo/**', 'expo-plugins/**'],
ignores: [".expo/**", "expo-plugins/**"],
},
baseConfig,
reactConfig,
+1 -1
View File
@@ -1 +1 @@
import 'expo-router/entry';
import "expo-router/entry";
+5 -5
View File
@@ -1,14 +1,14 @@
// Learn more: https://docs.expo.dev/guides/monorepos/
const path = require('node:path');
const { getDefaultConfig } = require('expo/metro-config');
const { FileStore } = require('metro-cache');
const { withNativewind } = require('nativewind/metro');
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
const { FileStore } = require("metro-cache");
const { withNativewind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
config.cacheStores = [
new FileStore({
root: path.join(__dirname, 'node_modules', '.cache', 'metro'),
root: path.join(__dirname, "node_modules", ".cache", "metro"),
}),
];
+25 -36
View File
@@ -1,11 +1,10 @@
{
"name": "@gib/expo",
"name": "@acme/expo",
"private": true,
"main": "index.ts",
"scripts": {
"clean": "git clean -xdf .cache .expo .turbo android ios node_modules",
"dev": "expo start",
"dev:tunnel": "expo start --tunnel",
"dev:android": "expo start --android",
"dev:ios": "expo start --ios",
"android": "expo run:android",
@@ -15,52 +14,42 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@convex-dev/auth": "catalog:convex",
"@expo/vector-icons": "^15.1.1",
"@gib/backend": "workspace:*",
"@legendapp/list": "^2.0.19",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10",
"@react-navigation/native": "^7.1.33",
"@sentry/react-native": "^7.13.0",
"convex": "catalog:convex",
"expo": "~54.0.33",
"expo-apple-authentication": "~8.0.8",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"@acme/backend": "workspace:*",
"@convex-dev/auth": "workspace:*",
"@legendapp/list": "^2.0.14",
"convex": "workspace:*",
"expo": "~54.0.20",
"expo-constants": "~18.0.10",
"expo-dev-client": "~6.0.16",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.13",
"expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.8",
"nativewind": "5.0.0-preview.2",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"react-native": "~0.81.6",
"react-native": "~0.81.5",
"react-native-css": "3.0.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.6",
"react-native-safe-area-context": "~5.6.2",
"react-native-reanimated": "~4.1.3",
"react-native-safe-area-context": "~5.6.1",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.2",
"react-native-worklets": "~0.5.2"
"react-native-worklets": "~0.5.1",
"superjson": "2.2.3"
},
"devDependencies": {
"@gib/eslint-config": "workspace:*",
"@gib/prettier-config": "workspace:*",
"@gib/tailwind-config": "workspace:*",
"@gib/tsconfig": "workspace:*",
"@acme/eslint-config": "workspace:*",
"@acme/prettier-config": "workspace:*",
"@acme/tailwind-config": "workspace:*",
"@acme/tsconfig": "workspace:*",
"@types/react": "catalog:react19",
"eslint": "catalog:",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"prettier": "@gib/prettier-config"
"prettier": "@acme/prettier-config"
}
+1 -1
View File
@@ -1 +1 @@
module.exports = require('@gib/tailwind-config/postcss-config');
module.exports = require("@acme/tailwind-config/postcss-config");
+19 -16
View File
@@ -1,30 +1,33 @@
import { useColorScheme } from 'react-native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { ConvexAuthProvider } from '@convex-dev/auth/react';
import { useColorScheme } from "react-native";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { QueryClientProvider } from "@tanstack/react-query";
import { convex } from '~/utils/convex';
import { queryClient } from "~/utils/api";
import '../styles.css';
import "../styles.css";
const RootLayout = () => {
// This is the main layout of the app
// It wraps your pages with the providers they need
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<ConvexAuthProvider client={convex}>
<QueryClientProvider client={queryClient}>
{/*
The Stack component displays the current page.
It also allows you to configure your screens
*/}
<Stack
screenOptions={{
headerStyle: {
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
backgroundColor: "#c03484",
},
headerTintColor: colorScheme === 'dark' ? '#fafaf9' : '#1c1917',
contentStyle: {
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
backgroundColor: colorScheme == "dark" ? "#09090B" : "#FFFFFF",
},
}}
/>
<StatusBar style='auto' />
</ConvexAuthProvider>
<StatusBar />
</QueryClientProvider>
);
};
export default RootLayout;
}
+162 -44
View File
@@ -1,54 +1,172 @@
import { Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Stack } from 'expo-router';
import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react';
import { useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Link, Stack } from "expo-router";
import { LegendList } from "@legendapp/list";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from '@gib/backend/convex/_generated/api.js';
import type { RouterOutputs } from "~/utils/api";
import { trpc } from "~/utils/api";
import { authClient } from "~/utils/auth";
const Index = () => {
const { isAuthenticated, isLoading } = useConvexAuth();
const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
function PostCard(props: {
post: RouterOutputs["post"]["all"][number];
onDelete: () => void;
}) {
return (
<View className="bg-muted flex flex-row rounded-lg p-4">
<View className="grow">
<Link
asChild
href={{
pathname: "/post/[id]",
params: { id: props.post.id },
}}
>
<Pressable className="">
<Text className="text-primary text-xl font-semibold">
{props.post.title}
</Text>
<Text className="text-foreground mt-2">{props.post.content}</Text>
</Pressable>
</Link>
</View>
<Pressable onPress={props.onDelete}>
<Text className="text-primary font-bold uppercase">Delete</Text>
</Pressable>
</View>
);
}
function CreatePost() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const { mutate, error } = useMutation(
trpc.post.create.mutationOptions({
async onSuccess() {
setTitle("");
setContent("");
await queryClient.invalidateQueries(trpc.post.all.queryFilter());
},
}),
);
return (
<SafeAreaView className='bg-background flex-1'>
<Stack.Screen options={{ title: 'Home' }} />
<View className='flex-1 items-center justify-center gap-4 p-6'>
<Text className='text-foreground text-4xl font-bold'>
Convex Monorepo
<View className="mt-4 flex gap-2">
<TextInput
className="border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight"
value={title}
onChangeText={setTitle}
placeholder="Title"
/>
{error?.data?.zodError?.fieldErrors.title && (
<Text className="text-destructive mb-2">
{error.data.zodError.fieldErrors.title}
</Text>
<Text className='text-muted-foreground text-center text-base'>
Your self-hosted Expo + Convex starter
)}
<TextInput
className="border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight"
value={content}
onChangeText={setContent}
placeholder="Content"
/>
{error?.data?.zodError?.fieldErrors.content && (
<Text className="text-destructive mb-2">
{error.data.zodError.fieldErrors.content}
</Text>
)}
<Pressable
className="bg-primary flex items-center rounded-sm p-2"
onPress={() => {
mutate({
title,
content,
});
}}
>
<Text className="text-foreground">Create</Text>
</Pressable>
{error?.data?.code === "UNAUTHORIZED" && (
<Text className="text-destructive mt-2">
You need to be logged in to create a post
</Text>
)}
</View>
);
}
function MobileAuth() {
const { data: session } = authClient.useSession();
return (
<>
<Text className="text-foreground pb-2 text-center text-xl font-semibold">
{session?.user.name ? `Hello, ${session.user.name}` : "Not logged in"}
</Text>
<Pressable
onPress={() =>
session
? authClient.signOut()
: authClient.signIn.social({
provider: "discord",
callbackURL: "/",
})
}
className="bg-primary flex items-center rounded-sm p-2"
>
<Text>{session ? "Sign Out" : "Sign In With Discord"}</Text>
</Pressable>
</>
);
}
export default function Index() {
const queryClient = useQueryClient();
const postQuery = useQuery(trpc.post.all.queryOptions());
const deletePostMutation = useMutation(
trpc.post.delete.mutationOptions({
onSettled: () =>
queryClient.invalidateQueries(trpc.post.all.queryFilter()),
}),
);
return (
<SafeAreaView className="bg-background">
{/* Changes page title visible on the header */}
<Stack.Screen options={{ title: "Home Page" }} />
<View className="bg-background h-full w-full p-4">
<Text className="text-foreground pb-2 text-center text-5xl font-bold">
Create <Text className="text-primary">T3</Text> Turbo
</Text>
{isLoading ? (
<Text className='text-muted-foreground'>Loading...</Text>
) : isAuthenticated ? (
<View className='w-full gap-3'>
<Text className='text-foreground text-center text-lg'>
Welcome{user?.name ? `, ${user.name}` : ''}!
</Text>
<Pressable
className='bg-primary items-center rounded-md p-3'
onPress={() => void signOut()}
>
<Text className='text-primary-foreground font-semibold'>
Sign Out
</Text>
</Pressable>
</View>
) : (
<View className='w-full gap-3'>
<Text className='text-muted-foreground text-center'>
Sign in to get started
</Text>
{/* Add sign-in UI here — see apps/next/src/app/(auth)/sign-in for patterns */}
</View>
)}
<MobileAuth />
<View className="py-2">
<Text className="text-primary font-semibold italic">
Press on a post
</Text>
</View>
<LegendList
data={postQuery.data ?? []}
estimatedItemSize={20}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View className="h-2" />}
renderItem={(p) => (
<PostCard
post={p.item}
onDelete={() => deletePostMutation.mutate(p.item.id)}
/>
)}
/>
<CreatePost />
</View>
</SafeAreaView>
);
};
export default Index;
}
+17 -14
View File
@@ -1,21 +1,24 @@
import { Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Stack, useLocalSearchParams } from 'expo-router';
import { SafeAreaView, Text, View } from "react-native";
import { Stack, useGlobalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
const Post = () => {
const { id } = useLocalSearchParams<{ id: string }>();
import { trpc } from "~/utils/api";
export default function Post() {
const { id } = useGlobalSearchParams<{ id: string }>();
const { data } = useQuery(trpc.post.byId.queryOptions({ id }));
if (!data) return null;
return (
<SafeAreaView className='bg-background flex-1'>
<Stack.Screen options={{ title: 'Post' }} />
<View className='flex-1 p-4'>
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
<Text className='text-muted-foreground mt-2'>
Implement your post detail screen here using Convex queries.
<SafeAreaView className="bg-background">
<Stack.Screen options={{ title: data.title }} />
<View className="h-full w-full p-4">
<Text className="text-primary py-2 text-3xl font-bold">
{data.title}
</Text>
<Text className="text-foreground py-4">{data.content}</Text>
</View>
</SafeAreaView>
);
};
export default Post;
}
+3 -3
View File
@@ -1,3 +1,3 @@
@import 'tailwindcss';
@import 'nativewind/theme';
@import '@gib/tailwind-config/theme';
@import "tailwindcss";
@import "nativewind/theme";
@import "@acme/tailwind-config/theme";
+50
View File
@@ -0,0 +1,50 @@
import { QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import superjson from "superjson";
import type { AppRouter } from "@acme/api";
import { authClient } from "./auth";
import { getBaseUrl } from "./base-url";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// ...
},
},
});
/**
* A set of typesafe hooks for consuming your API.
*/
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: createTRPCClient({
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
colorMode: "ansi",
}),
httpBatchLink({
transformer: superjson,
url: `${getBaseUrl()}/api/trpc`,
headers() {
const headers = new Map<string, string>();
headers.set("x-trpc-source", "expo-react");
const cookies = authClient.getCookie();
if (cookies) {
headers.set("Cookie", cookies);
}
return headers;
},
}),
],
}),
queryClient,
});
export type { RouterInputs, RouterOutputs } from "@acme/api";
+16
View File
@@ -0,0 +1,16 @@
import * as SecureStore from "expo-secure-store";
import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/react";
import { getBaseUrl } from "./base-url";
export const authClient = createAuthClient({
baseURL: getBaseUrl(),
plugins: [
expoClient({
scheme: "expo",
storagePrefix: "expo",
storage: SecureStore,
}),
],
});
+3 -3
View File
@@ -1,4 +1,4 @@
import Constants from 'expo-constants';
import Constants from "expo-constants";
/**
* Extend this function when going to production by
@@ -14,12 +14,12 @@ export const getBaseUrl = () => {
* baseUrl to your production API URL.
*/
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(':')[0];
const localhost = debuggerHost?.split(":")[0];
if (!localhost) {
// return "https://turbo.t3.gg";
throw new Error(
'Failed to get localhost. Please point to your production server.',
"Failed to get localhost. Please point to your production server.",
);
}
return `http://${localhost}:3000`;
-26
View File
@@ -1,26 +0,0 @@
import Constants from 'expo-constants';
import { ConvexReactClient } from 'convex/react';
const getConvexUrl = (): string => {
// Allow override via Expo extra config (set in app.config.ts for production)
const fromConfig = Constants.expoConfig?.extra?.convexUrl as
| string
| undefined;
if (fromConfig) return fromConfig;
// Fall back to deriving from the dev server host (same pattern as getBaseUrl)
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(':')[0];
if (!localhost) {
throw new Error(
'Could not determine Convex URL. Set extra.convexUrl in app.config.ts for production.',
);
}
// Point at the self-hosted Convex backend on the local network
// Update this port if your Convex backend runs on a different port
return `http://${localhost}:3210`;
};
export const convex = new ConvexReactClient(getConvexUrl());
+2 -2
View File
@@ -1,6 +1,6 @@
import * as SecureStore from 'expo-secure-store';
import * as SecureStore from "expo-secure-store";
const key = 'session_token';
const key = "session_token";
export const getToken = () => SecureStore.getItem(key);
export const deleteToken = () => SecureStore.deleteItemAsync(key);
+2 -2
View File
@@ -1,11 +1,11 @@
{
"extends": ["@gib/tsconfig/base.json"],
"extends": ["@acme/tsconfig/base.json"],
"compilerOptions": {
"jsx": "react-native",
"checkJs": false,
"moduleSuffixes": [".ios", ".android", ".native", ""],
"paths": {
"~/*": ["./src/*"]
"@/*": ["./src/*"]
}
},
"include": [
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://v2-8-20.turborepo.dev/schema.json",
"$schema": "https://turborepo.com/schema.json",
"extends": ["//"],
"tasks": {
"dev": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
import { defineConfig } from 'eslint/config';
import { baseConfig, restrictEnvAccess } from '@gib/eslint-config/base';
import { nextjsConfig } from '@gib/eslint-config/nextjs';
import { reactConfig } from '@gib/eslint-config/react';
export default defineConfig(
{
ignores: ['.next/**'],
},
baseConfig,
reactConfig,
nextjsConfig,
restrictEnvAccess,
);
-57
View File
@@ -1,57 +0,0 @@
import { withSentryConfig } from '@sentry/nextjs';
import { createJiti } from 'jiti';
import { withPlausibleProxy } from 'next-plausible';
const jiti = createJiti(import.meta.url);
await jiti.import('./src/env');
/** @type {import("next").NextConfig} */
const config = withPlausibleProxy({
customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
})({
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gbrown.org',
},
],
},
serverExternalPackages: ['require-in-the-middle'],
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
/** Enables hot reloading for local packages without a build step */
transpilePackages: ['@gib/backend', '@gib/ui'],
typescript: { ignoreBuildErrors: true },
});
const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: process.env.NEXT_PUBLIC_SENTRY_ORG,
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
export default withSentryConfig(config, sentryConfig);
-48
View File
@@ -1,48 +0,0 @@
{
"name": "@gib/next",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"build": "bun with-env next build",
"build:env": "bun with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "bun with-env next dev --turbo",
"dev:tunnel": "bun with-env next dev --turbo",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"start": "bun with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@convex-dev/auth": "catalog:convex",
"@gib/backend": "workspace:*",
"@gib/ui": "workspace:*",
"@sentry/nextjs": "^10.43.0",
"@t3-oss/env-nextjs": "^0.13.10",
"convex": "catalog:convex",
"next": "^16.1.7",
"next-plausible": "^3.12.5",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"require-in-the-middle": "^7.5.2",
"superjson": "2.2.3",
"zod": "catalog:"
},
"devDependencies": {
"@gib/eslint-config": "workspace:*",
"@gib/prettier-config": "workspace:*",
"@gib/tailwind-config": "workspace:*",
"@gib/tsconfig": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"eslint": "catalog:",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "^1.4.0",
"typescript": "catalog:"
},
"prettier": "@gib/prettier-config"
}
-1
View File
@@ -1 +0,0 @@
export { default } from '@gib/tailwind-config/postcss-config';
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 845 KiB

@@ -1,12 +0,0 @@
<svg width="382" height="146" viewBox="0 0 382 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M114.794 86.6648C111.454 83.6785 109.784 79.2644 109.784 73.434C109.784 67.6036 111.487 63.1896 114.896 60.2033C118.301 57.217 122.959 55.721 128.865 55.721C131.319 55.721 133.486 55.8973 135.372 56.2613C137.258 56.6197 139.063 57.2283 140.786 58.0929V67.5524C138.106 66.2157 135.064 65.5445 131.659 65.5445C128.66 65.5445 126.445 66.1417 125.018 67.3363C123.586 68.5308 122.873 70.5615 122.873 73.434C122.873 76.2099 123.575 78.2178 124.986 79.4578C126.391 80.7035 128.617 81.3236 131.665 81.3236C134.891 81.3236 137.955 80.5329 140.862 78.9573V88.8547C137.636 90.3849 133.615 91.1471 128.801 91.1471C122.797 91.1471 118.133 89.6511 114.794 86.6648Z" fill="#141414"/>
<path d="M143.77 73.4279C143.77 67.643 145.337 63.246 148.471 60.2312C151.605 57.2165 156.328 55.7148 162.645 55.7148C169.006 55.7148 173.761 57.2222 176.922 60.2312C180.078 63.2403 181.656 67.643 181.656 73.4279C181.656 85.2366 175.318 91.1409 162.645 91.1409C150.06 91.1466 143.77 85.2423 143.77 73.4279ZM167.179 79.4574C168.109 78.2116 168.574 76.2037 168.574 73.4335C168.574 70.7089 168.109 68.7123 167.179 67.4439C166.25 66.1754 164.737 65.544 162.645 65.544C160.603 65.544 159.122 66.1811 158.214 67.4439C157.306 68.7123 156.853 70.7089 156.853 73.4335C156.853 76.2094 157.306 78.2173 158.214 79.4574C159.122 80.7031 160.597 81.3231 162.645 81.3231C164.737 81.3231 166.244 80.6974 167.179 79.4574Z" fill="#141414"/>
<path d="M184.638 56.4315H196.629L196.97 59.014C198.288 58.0583 199.969 57.2677 202.011 56.6477C204.054 56.0276 206.167 55.7148 208.35 55.7148C212.392 55.7148 215.343 56.7671 217.207 58.8718C219.071 60.9764 220.001 64.2244 220.001 68.627V90.4299H207.194V69.9865C207.194 68.4564 206.864 67.3585 206.205 66.6873C205.546 66.0161 204.443 65.6862 202.898 65.6862C201.947 65.6862 200.968 65.9137 199.969 66.3688C198.969 66.8239 198.131 67.4097 197.445 68.1265V90.4299H184.638V56.4315Z" fill="#141414"/>
<path d="M220.038 56.4317H233.391L239.524 76.3689L245.658 56.4317H259.011L246.268 90.4301H232.775L220.038 56.4317Z" fill="#141414"/>
<path d="M263.043 87.5062C259.195 84.4687 257.396 79.1957 257.396 73.5018C257.396 67.9558 258.828 63.3882 262.097 60.2312C265.366 57.0743 270.349 55.7148 276.639 55.7148C282.426 55.7148 286.976 57.1255 290.3 59.9468C293.618 62.7682 295.282 66.6191 295.282 71.4939V77.4494H270.927C271.532 79.2184 272.299 80.4983 274.185 81.289C276.071 82.0796 278.703 82.4721 282.07 82.4721C284.08 82.4721 286.133 82.3071 288.219 81.9715C288.954 81.8521 290.165 81.6644 290.802 81.5222V89.7871C287.619 90.6972 283.377 91.1523 278.595 91.1523C272.159 91.1466 266.89 90.5437 263.043 87.5062ZM281.826 70.1344C281.826 68.4507 279.984 64.8273 276.282 64.8273C272.942 64.8273 270.738 68.3938 270.738 70.1344H281.826Z" fill="#141414"/>
<path d="M305.338 73.1437L293.346 56.4317H307.245L331.773 90.4301H317.74L312.287 82.825L306.835 90.4301H292.865L305.338 73.1437Z" fill="#141414"/>
<path d="M317.431 56.4317H331.265L320.647 71.3178L313.622 61.7787L317.431 56.4317Z" fill="#141414"/>
<path d="M82.2808 87.6517C89.652 86.8381 96.6012 82.9353 100.427 76.4211C98.6156 92.5331 80.8853 102.717 66.413 96.4643C65.0795 95.8897 63.9316 94.9339 63.1438 93.705C59.8915 88.6302 58.8224 82.1729 60.3585 76.313C64.7475 83.8399 73.6717 88.4539 82.2808 87.6517Z" fill="#141414"/>
<path d="M60.0895 71.5852C57.1016 78.4464 56.9722 86.4797 60.6353 93.0906C47.7442 83.453 47.8848 62.8294 60.4778 53.2885C61.6425 52.4067 63.0267 51.8833 64.4785 51.8036C70.4486 51.4907 76.5144 53.7835 80.7683 58.0561C72.1254 58.1415 63.7076 63.643 60.0895 71.5852Z" fill="#141414"/>
<path d="M84.9366 60.1673C80.5757 54.1253 73.7503 50.0119 66.2722 49.8868C80.7277 43.3669 98.5086 53.9375 100.444 69.5659C100.624 71.0167 100.388 72.4959 99.7409 73.8044C97.04 79.2547 92.032 83.4819 86.1801 85.0464C90.4678 77.144 89.9388 67.4894 84.9366 60.1673Z" fill="#141414"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

@@ -1,12 +0,0 @@
<svg width="382" height="146" viewBox="0 0 382 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M114.794 86.6648C111.454 83.6785 109.784 79.2644 109.784 73.434C109.784 67.6036 111.487 63.1896 114.896 60.2033C118.301 57.2169 122.959 55.7209 128.865 55.7209C131.319 55.7209 133.486 55.8973 135.372 56.2613C137.258 56.6197 139.063 57.2283 140.786 58.0929V67.5524C138.106 66.2157 135.064 65.5445 131.659 65.5445C128.66 65.5445 126.445 66.1417 125.018 67.3363C123.586 68.5308 122.873 70.5615 122.873 73.434C122.873 76.2099 123.575 78.2178 124.986 79.4578C126.391 80.7035 128.617 81.3235 131.665 81.3235C134.891 81.3235 137.955 80.5329 140.862 78.9573V88.8547C137.636 90.3849 133.615 91.1471 128.801 91.1471C122.797 91.1471 118.133 89.6511 114.794 86.6648Z" fill="#141414"/>
<path d="M143.77 73.4278C143.77 67.6429 145.337 63.246 148.471 60.2312C151.605 57.2165 156.328 55.7148 162.645 55.7148C169.006 55.7148 173.761 57.2222 176.922 60.2312C180.078 63.2403 181.656 67.6429 181.656 73.4278C181.656 85.2366 175.318 91.1409 162.645 91.1409C150.06 91.1466 143.77 85.2422 143.77 73.4278ZM167.179 79.4573C168.109 78.2116 168.574 76.2037 168.574 73.4335C168.574 70.7089 168.109 68.7123 167.179 67.4439C166.25 66.1754 164.737 65.544 162.645 65.544C160.603 65.544 159.122 66.1811 158.214 67.4439C157.306 68.7123 156.853 70.7089 156.853 73.4335C156.853 76.2094 157.306 78.2173 158.214 79.4573C159.122 80.7031 160.597 81.3231 162.645 81.3231C164.737 81.3231 166.244 80.6974 167.179 79.4573Z" fill="#141414"/>
<path d="M184.638 56.4315H196.629L196.97 59.0139C198.288 58.0583 199.969 57.2677 202.011 56.6476C204.054 56.0276 206.167 55.7148 208.35 55.7148C212.392 55.7148 215.343 56.7671 217.207 58.8717C219.071 60.9764 220.001 64.2243 220.001 68.627V90.4299H207.194V69.9865C207.194 68.4564 206.864 67.3585 206.205 66.6873C205.546 66.0161 204.443 65.6862 202.898 65.6862C201.947 65.6862 200.968 65.9137 199.969 66.3688C198.969 66.8238 198.131 67.4097 197.445 68.1264V90.4299H184.638V56.4315Z" fill="#141414"/>
<path d="M220.038 56.4317H233.391L239.524 76.3689L245.658 56.4317H259.011L246.268 90.4301H232.775L220.038 56.4317Z" fill="#141414"/>
<path d="M263.043 87.5061C259.195 84.4686 257.396 79.1957 257.396 73.5018C257.396 67.9558 258.828 63.3882 262.097 60.2312C265.366 57.0743 270.349 55.7148 276.639 55.7148C282.426 55.7148 286.976 57.1255 290.3 59.9468C293.618 62.7682 295.282 66.6191 295.282 71.4939V77.4494H270.927C271.532 79.2184 272.299 80.4983 274.185 81.2889C276.071 82.0796 278.703 82.4721 282.07 82.4721C284.08 82.4721 286.133 82.3071 288.219 81.9715C288.954 81.8521 290.165 81.6644 290.802 81.5222V89.7871C287.619 90.6972 283.377 91.1523 278.595 91.1523C272.159 91.1466 266.89 90.5436 263.043 87.5061ZM281.826 70.1344C281.826 68.4507 279.984 64.8273 276.282 64.8273C272.942 64.8273 270.738 68.3938 270.738 70.1344H281.826Z" fill="#141414"/>
<path d="M305.338 73.1436L293.346 56.4317H307.245L331.773 90.4301H317.74L312.287 82.825L306.835 90.4301H292.865L305.338 73.1436Z" fill="#141414"/>
<path d="M317.431 56.4317H331.265L320.647 71.3177L313.622 61.7786L317.431 56.4317Z" fill="#141414"/>
<path d="M82.2808 87.6516C89.652 86.8381 96.6012 82.9352 100.427 76.421C98.6156 92.533 80.8853 102.717 66.413 96.4643C65.0795 95.8897 63.9316 94.9339 63.1438 93.705C59.8915 88.6302 58.8224 82.1729 60.3585 76.3129C64.7475 83.8398 73.6717 88.4538 82.2808 87.6516Z" fill="#F3B01C"/>
<path d="M60.0895 71.5852C57.1016 78.4465 56.9722 86.4797 60.6353 93.0906C47.7442 83.453 47.8848 62.8294 60.4778 53.2885C61.6425 52.4067 63.0267 51.8833 64.4785 51.8036C70.4486 51.4907 76.5144 53.7835 80.7683 58.0561C72.1254 58.1415 63.7076 63.643 60.0895 71.5852Z" fill="#8D2676"/>
<path d="M84.9366 60.1673C80.5757 54.1253 73.7503 50.0119 66.2722 49.8868C80.7277 43.3669 98.5086 53.9375 100.444 69.5659C100.624 71.0167 100.388 72.4959 99.7409 73.8044C97.04 79.2547 92.032 83.4819 86.1801 85.0464C90.4678 77.144 89.9388 67.4893 84.9366 60.1673Z" fill="#EE342F"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,12 +0,0 @@
<svg width="382" height="146" viewBox="0 0 382 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M114.794 86.6648C111.454 83.6785 109.784 79.2644 109.784 73.434C109.784 67.6036 111.487 63.1896 114.896 60.2033C118.301 57.217 122.959 55.721 128.865 55.721C131.319 55.721 133.486 55.8973 135.372 56.2613C137.258 56.6197 139.063 57.2283 140.786 58.0929V67.5524C138.106 66.2157 135.064 65.5445 131.659 65.5445C128.66 65.5445 126.445 66.1417 125.018 67.3363C123.586 68.5308 122.873 70.5615 122.873 73.434C122.873 76.2099 123.575 78.2178 124.986 79.4578C126.391 80.7035 128.617 81.3236 131.665 81.3236C134.891 81.3236 137.955 80.5329 140.862 78.9573V88.8547C137.636 90.3849 133.615 91.1471 128.801 91.1471C122.797 91.1471 118.133 89.6511 114.794 86.6648Z" fill="white"/>
<path d="M143.77 73.4279C143.77 67.643 145.337 63.246 148.471 60.2312C151.605 57.2165 156.328 55.7148 162.645 55.7148C169.006 55.7148 173.761 57.2222 176.922 60.2312C180.078 63.2403 181.656 67.643 181.656 73.4279C181.656 85.2366 175.318 91.1409 162.645 91.1409C150.06 91.1466 143.77 85.2423 143.77 73.4279ZM167.179 79.4574C168.109 78.2116 168.574 76.2037 168.574 73.4335C168.574 70.7089 168.109 68.7123 167.179 67.4439C166.25 66.1754 164.737 65.544 162.645 65.544C160.603 65.544 159.122 66.1811 158.214 67.4439C157.306 68.7123 156.853 70.7089 156.853 73.4335C156.853 76.2094 157.306 78.2173 158.214 79.4574C159.122 80.7031 160.597 81.3231 162.645 81.3231C164.737 81.3231 166.244 80.6974 167.179 79.4574Z" fill="white"/>
<path d="M184.638 56.4315H196.629L196.97 59.014C198.288 58.0583 199.969 57.2677 202.011 56.6477C204.054 56.0276 206.167 55.7148 208.35 55.7148C212.392 55.7148 215.343 56.7671 217.207 58.8718C219.071 60.9764 220.001 64.2244 220.001 68.627V90.4299H207.194V69.9865C207.194 68.4564 206.864 67.3585 206.205 66.6873C205.546 66.0161 204.443 65.6862 202.898 65.6862C201.947 65.6862 200.968 65.9137 199.969 66.3688C198.969 66.8239 198.131 67.4097 197.445 68.1265V90.4299H184.638V56.4315Z" fill="white"/>
<path d="M220.038 56.4317H233.391L239.524 76.3689L245.658 56.4317H259.011L246.268 90.4301H232.775L220.038 56.4317Z" fill="white"/>
<path d="M263.043 87.5062C259.195 84.4687 257.396 79.1957 257.396 73.5018C257.396 67.9558 258.828 63.3882 262.097 60.2312C265.366 57.0743 270.349 55.7148 276.639 55.7148C282.426 55.7148 286.976 57.1255 290.3 59.9468C293.618 62.7682 295.282 66.6191 295.282 71.4939V77.4494H270.927C271.532 79.2184 272.299 80.4983 274.185 81.289C276.071 82.0796 278.703 82.4721 282.07 82.4721C284.08 82.4721 286.133 82.3071 288.219 81.9715C288.954 81.8521 290.165 81.6644 290.802 81.5222V89.7871C287.619 90.6972 283.377 91.1523 278.595 91.1523C272.159 91.1466 266.89 90.5437 263.043 87.5062ZM281.826 70.1344C281.826 68.4507 279.984 64.8273 276.282 64.8273C272.942 64.8273 270.738 68.3938 270.738 70.1344H281.826Z" fill="white"/>
<path d="M305.338 73.1437L293.346 56.4317H307.245L331.773 90.4301H317.74L312.287 82.825L306.835 90.4301H292.865L305.338 73.1437Z" fill="white"/>
<path d="M317.431 56.4317H331.265L320.647 71.3178L313.622 61.7786L317.431 56.4317Z" fill="white"/>
<path d="M82.2808 87.6517C89.652 86.8381 96.6012 82.9353 100.427 76.4211C98.6156 92.533 80.8853 102.717 66.413 96.4643C65.0795 95.8897 63.9316 94.9339 63.1438 93.705C59.8915 88.6302 58.8224 82.1729 60.3585 76.313C64.7475 83.8399 73.6717 88.4538 82.2808 87.6517Z" fill="white"/>
<path d="M60.0895 71.5852C57.1016 78.4464 56.9722 86.4797 60.6353 93.0906C47.7442 83.453 47.8848 62.8294 60.4778 53.2885C61.6425 52.4067 63.0267 51.8833 64.4785 51.8036C70.4486 51.4907 76.5144 53.7835 80.7683 58.0561C72.1254 58.1415 63.7076 63.643 60.0895 71.5852Z" fill="white"/>
<path d="M84.9366 60.1673C80.5757 54.1253 73.7503 50.0119 66.2722 49.8868C80.7277 43.3669 98.5086 53.9375 100.444 69.5659C100.624 71.0167 100.388 72.4959 99.7409 73.8044C97.04 79.2547 92.032 83.4819 86.1801 85.0464C90.4678 77.144 89.9388 67.4893 84.9366 60.1673Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

@@ -1,5 +0,0 @@
<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.128 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="#141414"/>
<path d="M53.4012 90.1735C46.0375 107.19 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3096 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.8309 62.318 70.4756 53.4012 90.1735Z" fill="#141414"/>
<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="#141414"/>
</svg>

Before

Width:  |  Height:  |  Size: 948 B

@@ -1,5 +0,0 @@
<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.128 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="#F3B01C"/>
<path d="M53.4012 90.1735C46.0375 107.191 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3097 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.831 62.318 70.4756 53.4012 90.1735Z" fill="#8D2676"/>
<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="#EE342F"/>
</svg>

Before

Width:  |  Height:  |  Size: 948 B

@@ -1,5 +0,0 @@
<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.127 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="white"/>
<path d="M53.4012 90.1735C46.0375 107.19 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3096 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.8309 62.318 70.4756 53.4012 90.1735Z" fill="white"/>
<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 942 B

@@ -1,9 +0,0 @@
<svg width="322" height="146" viewBox="0 0 322 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M55.2938 86.6648C51.9542 83.6785 50.2844 79.2644 50.2844 73.434C50.2844 67.6036 51.9866 63.1896 55.3965 60.2033C58.8009 57.2169 63.4591 55.7209 69.3655 55.7209C71.8188 55.7209 73.9858 55.8973 75.8717 56.2613C77.7577 56.6197 79.5626 57.2283 81.2864 58.0929V67.5524C78.6061 66.2157 75.5637 65.5445 72.1593 65.5445C69.1601 65.5445 66.9446 66.1417 65.5179 67.3363C64.0859 68.5308 63.3726 70.5615 63.3726 73.434C63.3726 76.2099 64.0751 78.2178 65.4855 79.4578C66.8905 80.7035 69.1169 81.3235 72.1647 81.3235C75.3908 81.3235 78.4548 80.5329 81.3621 78.9573V88.8547C78.136 90.3849 74.1155 91.1471 69.3006 91.1471C63.2969 91.1471 58.6334 89.6511 55.2938 86.6648Z" fill="#141414"/>
<path d="M84.2698 73.4278C84.2698 67.6429 85.8369 63.246 88.9711 60.2312C92.1054 57.2165 96.8284 55.7148 103.145 55.7148C109.506 55.7148 114.261 57.2222 117.422 60.2312C120.578 63.2403 122.156 67.6429 122.156 73.4278C122.156 85.2366 115.818 91.1409 103.145 91.1409C90.5599 91.1466 84.2698 85.2422 84.2698 73.4278ZM107.679 79.4573C108.609 78.2116 109.074 76.2037 109.074 73.4335C109.074 70.7089 108.609 68.7123 107.679 67.4439C106.75 66.1754 105.237 65.544 103.145 65.544C101.103 65.544 99.6222 66.1811 98.7143 67.4439C97.8065 68.7123 97.3525 70.7089 97.3525 73.4335C97.3525 76.2094 97.8065 78.2173 98.7143 79.4573C99.6222 80.7031 101.097 81.3231 103.145 81.3231C105.237 81.3231 106.744 80.6974 107.679 79.4573Z" fill="#141414"/>
<path d="M125.138 56.4315H137.129L137.47 59.0139C138.788 58.0583 140.469 57.2677 142.511 56.6476C144.554 56.0276 146.667 55.7148 148.85 55.7148C152.892 55.7148 155.843 56.7671 157.707 58.8717C159.571 60.9764 160.501 64.2243 160.501 68.627V90.4299H147.694V69.9865C147.694 68.4564 147.364 67.3585 146.705 66.6873C146.046 66.0161 144.943 65.6862 143.398 65.6862C142.447 65.6862 141.468 65.9137 140.469 66.3688C139.469 66.8238 138.631 67.4097 137.945 68.1264V90.4299H125.138V56.4315Z" fill="#141414"/>
<path d="M160.538 56.4317H173.891L180.024 76.3689L186.158 56.4317H199.511L186.768 90.4301H173.275L160.538 56.4317Z" fill="#141414"/>
<path d="M203.543 87.5061C199.695 84.4686 197.896 79.1957 197.896 73.5018C197.896 67.9558 199.328 63.3882 202.597 60.2312C205.866 57.0743 210.849 55.7148 217.139 55.7148C222.926 55.7148 227.476 57.1255 230.8 59.9468C234.118 62.7682 235.782 66.6191 235.782 71.4939V77.4494H211.427C212.032 79.2184 212.799 80.4983 214.685 81.2889C216.571 82.0796 219.203 82.4721 222.57 82.4721C224.58 82.4721 226.633 82.3071 228.719 81.9715C229.454 81.8521 230.665 81.6644 231.302 81.5222V89.7871C228.119 90.6972 223.877 91.1523 219.095 91.1523C212.659 91.1466 207.39 90.5436 203.543 87.5061ZM222.326 70.1344C222.326 68.4507 220.484 64.8273 216.782 64.8273C213.442 64.8273 211.238 68.3938 211.238 70.1344H222.326Z" fill="#141414"/>
<path d="M245.838 73.1436L233.846 56.4317H247.745L272.273 90.4301H258.24L252.787 82.825L247.335 90.4301H233.365L245.838 73.1436Z" fill="#141414"/>
<path d="M257.931 56.4317H271.765L261.147 71.3177L254.122 61.7786L257.931 56.4317Z" fill="#141414"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,9 +0,0 @@
<svg width="322" height="146" viewBox="0 0 322 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M55.2938 86.6648C51.9542 83.6785 50.2844 79.2644 50.2844 73.434C50.2844 67.6036 51.9866 63.1896 55.3965 60.2033C58.8009 57.2169 63.4591 55.7209 69.3655 55.7209C71.8188 55.7209 73.9858 55.8973 75.8717 56.2613C77.7577 56.6197 79.5626 57.2283 81.2864 58.0929V67.5524C78.6061 66.2157 75.5637 65.5445 72.1593 65.5445C69.1601 65.5445 66.9446 66.1417 65.5179 67.3363C64.0859 68.5308 63.3726 70.5615 63.3726 73.434C63.3726 76.2099 64.0751 78.2178 65.4855 79.4578C66.8905 80.7035 69.1169 81.3235 72.1647 81.3235C75.3908 81.3235 78.4548 80.5329 81.3621 78.9573V88.8547C78.136 90.3849 74.1155 91.1471 69.3006 91.1471C63.2969 91.1471 58.6334 89.6511 55.2938 86.6648Z" fill="white"/>
<path d="M84.2698 73.4278C84.2698 67.6429 85.8369 63.246 88.9711 60.2312C92.1054 57.2165 96.8284 55.7148 103.145 55.7148C109.506 55.7148 114.261 57.2222 117.422 60.2312C120.578 63.2403 122.156 67.6429 122.156 73.4278C122.156 85.2366 115.818 91.1409 103.145 91.1409C90.5599 91.1466 84.2698 85.2422 84.2698 73.4278ZM107.679 79.4573C108.609 78.2116 109.074 76.2037 109.074 73.4335C109.074 70.7089 108.609 68.7123 107.679 67.4439C106.75 66.1754 105.237 65.544 103.145 65.544C101.103 65.544 99.6222 66.1811 98.7143 67.4439C97.8065 68.7123 97.3525 70.7089 97.3525 73.4335C97.3525 76.2094 97.8065 78.2173 98.7143 79.4573C99.6222 80.7031 101.097 81.3231 103.145 81.3231C105.237 81.3231 106.744 80.6974 107.679 79.4573Z" fill="white"/>
<path d="M125.138 56.4315H137.129L137.47 59.0139C138.788 58.0583 140.469 57.2677 142.511 56.6476C144.554 56.0276 146.667 55.7148 148.85 55.7148C152.892 55.7148 155.843 56.7671 157.707 58.8717C159.571 60.9764 160.501 64.2243 160.501 68.627V90.4299H147.694V69.9865C147.694 68.4564 147.364 67.3585 146.705 66.6873C146.046 66.0161 144.943 65.6862 143.398 65.6862C142.447 65.6862 141.468 65.9137 140.469 66.3688C139.469 66.8238 138.631 67.4097 137.945 68.1264V90.4299H125.138V56.4315Z" fill="white"/>
<path d="M160.538 56.4317H173.891L180.024 76.3689L186.158 56.4317H199.511L186.768 90.4301H173.275L160.538 56.4317Z" fill="white"/>
<path d="M203.543 87.5061C199.695 84.4686 197.896 79.1957 197.896 73.5018C197.896 67.9558 199.328 63.3882 202.597 60.2312C205.866 57.0743 210.849 55.7148 217.139 55.7148C222.926 55.7148 227.476 57.1255 230.8 59.9468C234.118 62.7682 235.782 66.6191 235.782 71.4939V77.4494H211.427C212.032 79.2184 212.799 80.4983 214.685 81.2889C216.571 82.0796 219.203 82.4721 222.57 82.4721C224.58 82.4721 226.633 82.3071 228.719 81.9715C229.454 81.8521 230.665 81.6644 231.302 81.5222V89.7871C228.119 90.6972 223.877 91.1523 219.095 91.1523C212.659 91.1466 207.39 90.5436 203.543 87.5061ZM222.326 70.1344C222.326 68.4507 220.484 64.8273 216.782 64.8273C213.442 64.8273 211.238 68.3938 211.238 70.1344H222.326Z" fill="white"/>
<path d="M245.838 73.1436L233.846 56.4317H247.745L272.273 90.4301H258.24L252.787 82.825L247.335 90.4301H233.365L245.838 73.1436Z" fill="white"/>
<path d="M257.931 56.4317H271.765L261.147 71.3177L254.122 61.7786L257.931 56.4317Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M414.4 376.5 200 379.6l-1.4-256.7 103.5-15.2 108.8-1.5z" style="fill:#fff"/><path d="M502.6 103.7c-3.3-3.3-7.8-3.3-7.8-3.3s-95.5 5.4-144.9 6.5c-10.8.2-21.6.5-32.3.6V203c-4.5-2.1-9-4.3-13.5-6.4 0-29.6-.1-88.9-.1-88.9-23.6.3-72.7-1.8-72.7-1.8s-115.2-5.8-127.7-6.9c-8-.5-18.3-1.7-31.8 1.2-7.1 1.5-27.3 6-43.8 21.9C-8.7 154.8.7 206.7 1.9 214.5c1.4 9.5 5.6 36 25.8 59 37.3 45.7 117.6 44.6 117.6 44.6s9.9 23.5 24.9 45.2c20.4 27 41.3 48 61.7 50.5 51.3 0 153.9-.1 153.9-.1s9.8.1 23-8.4c11.4-6.9 21.6-19.1 21.6-19.1s10.5-11.2 25.2-36.9c4.5-7.9 8.2-15.6 11.5-22.8 0 0 45-95.4 45-188.2-1-28-7.9-33-9.5-34.6M97.7 269.9c-21.1-6.9-30.1-15.2-30.1-15.2S52 243.8 44.2 222.3c-13.4-36-1.1-58-1.1-58s6.8-18.3 31.4-24.4c11.2-3 25.2-2.5 25.2-2.5s5.8 48.4 12.8 76.7c5.9 23.8 20.2 63.3 20.2 63.3s-21.3-2.6-35-7.5m289.4-4.5c-5.2 12.6-44.8 92.1-44.8 92.1s-5 11.8-16 12.5c-4.7.3-8.4-1-8.4-1s-.2-.1-4.3-1.7l-92-44.8s-8.9-4.6-10.4-12.7c-1.8-6.6 2.2-14.7 2.2-14.7l44.2-91.1s3.9-7.9 9.9-10.6c.5-.2 1.9-.8 3.7-1.2 6.6-1.7 14.7 2.3 14.7 2.3l18.4 8.9c-3.7 7.6-7.5 15.2-11.2 22.9-5.5-.1-10.5 2.9-13.1 7.7-2.8 5.1-2.2 11.5 1.5 16.1-6.6 13.8-13.3 27.5-19.9 41.1-6.7.1-12.5 4.7-14.1 11.2-1.5 6.5 1.6 13.3 7.4 16.3 6.3 3.3 14.3 1.5 18.5-4.4 4.2-5.8 3.5-13.8-1.5-18.8l19.5-40c1.2.1 3 .2 5-.4 3.3-.7 5.8-2.9 5.8-2.9 3.4 1.5 7 3.1 10.8 5 3.9 2 7.6 4 10.9 5.9.7.4 1.5.9 2.3 1.5 1.3 1.1 2.8 2.5 3.8 4.5 1.5 4.5-1.5 12.1-1.5 12.1-1.9 6.2-15 33.1-15 33.1-6.6-.2-12.5 4.1-14.4 10.2-2.1 6.6.9 14.1 7.2 17.3 6.4 3.3 14.2 1.4 18.3-4.3 4.1-5.5 3.7-13.3-.9-18.4l4.6-9.2c4.1-8.5 11-24.8 11-24.8.7-1.4 4.6-8.4 2.2-17.3-2-9.3-10.3-13.6-10.3-13.6-9.9-6.4-23.8-12.4-23.8-12.4s0-3.3-.9-5.8-2.3-4.2-3.2-5.1c3.6-7.6 7.4-15.1 11-22.6l61.8 29.9s10.3 4.6 12.5 13.2c1.5 6-.4 11.4-1.5 14" style="fill:#609926"/></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,14 +0,0 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Forgot Password',
};
};
const ForgotPasswordLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default ForgotPasswordLayout;
@@ -1,300 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { PASSWORD_MAX, PASSWORD_MIN } from '@gib/backend/types';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
SubmitButton,
} from '@gib/ui';
const forgotPasswordSchema = z.object({
email: z.email({ message: 'Invalid email.' }),
});
const resetVerificationSchema = z
.object({
code: z.string({ message: 'Invalid code.' }),
newPassword: z
.string()
.min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
})
.max(PASSWORD_MAX, {
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string().min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
}),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const ForgotPassword = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'reset' | 'reset-verification'>('reset');
const [email, setEmail] = useState<string>('');
const [loading, setLoading] = useState(false);
const [code, setCode] = useState<string>('');
const router = useRouter();
const forgotPasswordForm = useForm<z.infer<typeof forgotPasswordSchema>>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: { email },
});
const resetVerificationForm = useForm<
z.infer<typeof resetVerificationSchema>
>({
resolver: zodResolver(resetVerificationSchema),
defaultValues: { code, newPassword: '', confirmPassword: '' },
});
const handleForgotPasswordSubmit = async (
values: z.infer<typeof forgotPasswordSchema>,
) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('reset-verification');
});
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
forgotPasswordForm.reset();
setLoading(false);
}
};
const handleResetVerificationSubmit = async (
values: z.infer<typeof resetVerificationSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('newPassword', values.newPassword);
formData.append('email', email);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData);
router.push('/');
} catch (error) {
console.error('Error resetting password: ', error);
toast.error('Error resetting password.');
} finally {
resetVerificationForm.reset();
setLoading(false);
}
};
return (
<div className='flex flex-col items-center'>
<Card className='bg-card/25 min-h-[400px] w-sm p-4 lg:w-md'>
<CardHeader className='flex flex-col items-center gap-4'>
{flow === 'reset' ? (
<>
<CardTitle className='text-2xl font-bold'>
Forgot Password
</CardTitle>
<CardDescription>
Enter your email address and we will send you a link to reset
your password.
</CardDescription>
</>
) : (
<>
<CardTitle className='text-2xl font-bold'>
Reset Password
</CardTitle>
<CardDescription>
Enter your code and new password and we will reset your
password.
</CardDescription>
</>
)}
</CardHeader>
<CardContent>
<Card className='bg-card/50'>
<CardContent>
{flow === 'reset' ? (
<Form {...forgotPasswordForm}>
<form
onSubmit={forgotPasswordForm.handleSubmit(
handleForgotPasswordSubmit,
)}
className='flex flex-col space-y-4'
>
<FormField
control={forgotPasswordForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Sending Email...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Send Email
</SubmitButton>
</form>
</Form>
) : (
<Form {...resetVerificationForm}>
<form
onSubmit={resetVerificationForm.handleSubmit(
handleResetVerificationSubmit,
)}
className='flex flex-col space-y-4'
>
<FormField
control={resetVerificationForm.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your
phone.
</FormDescription>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name='newPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
New Password
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={resetVerificationForm.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Resetting Password...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Reset Password
</SubmitButton>
</form>
</Form>
)}
</CardContent>
</Card>
</CardContent>
</Card>
</div>
);
};
export default ForgotPassword;
@@ -1,14 +0,0 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Profile',
};
};
const ProfileLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default ProfileLayout;
-51
View File
@@ -1,51 +0,0 @@
'use server';
import {
AvatarUpload,
ProfileHeader,
ResetPasswordForm,
SignOutForm,
UserInfoForm,
} from '@/components/layout/auth/profile';
import { preloadQuery } from 'convex/nextjs';
import { api } from '@gib/backend/convex/_generated/api.js';
import { Card, Separator } from '@gib/ui';
const Profile = async () => {
const preloadedUser = await preloadQuery(api.auth.getUser, {});
const preloadedUserProvider = await preloadQuery(
api.auth.getUserProvider,
{},
);
return (
<main className='container mx-auto px-4 py-12 md:py-16'>
<div className='mx-auto max-w-3xl'>
{/* Page Header */}
<div className='mb-8 text-center'>
<h1 className='mb-2 text-3xl font-bold tracking-tight sm:text-4xl'>
Your Profile
</h1>
<p className='text-muted-foreground'>
Manage your personal information and preferences
</p>
</div>
{/* Profile Card */}
<Card className='border-border/40'>
<ProfileHeader />
<AvatarUpload preloadedUser={preloadedUser} />
<Separator className='my-6' />
<UserInfoForm
preloadedUser={preloadedUser}
preloadedProvider={preloadedUserProvider}
/>
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
<Separator className='my-6' />
<SignOutForm />
</Card>
</div>
</main>
);
};
export default Profile;
@@ -1,14 +0,0 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Sign In',
};
};
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <>{children}</>;
};
export default SignInLayout;
-460
View File
@@ -1,460 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { GibsAuthSignInButton } from '@/components/layout/auth/buttons';
import { useAuthActions } from '@convex-dev/auth/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ConvexError } from 'convex/values';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { PASSWORD_MAX, PASSWORD_MIN, PASSWORD_REGEX } from '@gib/backend/types';
import {
Card,
CardContent,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
Separator,
SubmitButton,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@gib/ui';
const signInFormSchema = z.object({
email: z.email({
message: 'Please enter a valid email address.',
}),
password: z.string().regex(PASSWORD_REGEX, {
message: 'Incorrect password. Does not meet requirements.',
}),
});
const signUpFormSchema = z
.object({
name: z.string().min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.email({
message: 'Please enter a valid email address.',
}),
password: z
.string()
.min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
})
.max(PASSWORD_MAX, {
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string().min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const verifyEmailFormSchema = z.object({
code: z.string({ message: 'Invalid code.' }),
});
const SignIn = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
'signIn',
);
const [email, setEmail] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
resolver: zodResolver(signInFormSchema),
defaultValues: { email: '', password: '' },
});
const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
name: '',
email,
password: '',
confirmPassword: '',
},
});
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
resolver: zodResolver(verifyEmailFormSchema),
defaultValues: { code },
});
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/'));
} catch (error) {
console.error('Error signing in:', error);
toast.error('Error signing in.');
} finally {
signInForm.reset();
setLoading(false);
}
};
const handleSignUp = async (values: z.infer<typeof signUpFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
formData.append('flow', flow);
formData.append('name', values.name);
setLoading(true);
try {
if (values.confirmPassword !== values.password)
throw new ConvexError('Passwords do not match.');
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('email-verification');
});
} catch (error) {
console.error('Error signing up:', error);
toast.error('Error signing up.');
} finally {
signUpForm.reset();
setLoading(false);
}
};
const handleVerifyEmail = async (
values: z.infer<typeof verifyEmailFormSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('flow', flow);
formData.append('email', email);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/'));
} catch (error) {
console.error('Error verifying email:', error);
toast.error('Error verifying email.');
} finally {
verifyEmailForm.reset();
setLoading(false);
}
};
if (flow === 'email-verification') {
return (
<div className='flex flex-col items-center'>
<Card className='bg-card/25 min-h-[720px] w-md p-4'>
<CardContent>
<div className='mb-6 text-center'>
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
<p className='text-muted-foreground'>We sent a code to {email}</p>
</div>
<Form {...verifyEmailForm}>
<form
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
className='flex flex-col space-y-8'
>
<FormField
control={verifyEmailForm.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your email.
</FormDescription>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Verify Email
</SubmitButton>
</form>
</Form>
<div className='mt-4 text-center'>
<button
onClick={() => setFlow('signUp')}
className='text-muted-foreground text-sm hover:underline'
>
Back to Sign Up
</button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className='flex flex-col items-center'>
<Card className='bg-card/25 min-h-[720px] w-md p-4'>
<Tabs
defaultValue={flow}
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
className='flex-col items-center'
>
<TabsList>
<TabsTrigger
value='signIn'
className='cursor-pointer px-6 py-2 text-2xl font-bold'
>
Sign In
</TabsTrigger>
<TabsTrigger
value='signUp'
className='cursor-pointer px-6 py-2 text-2xl font-bold'
>
Sign Up
</TabsTrigger>
</TabsList>
<TabsContent
value='signIn'
className='flex min-h-[560px] flex-row items-center'
>
<Card className='bg-card/50 min-w-xs py-10 sm:min-w-sm'>
<CardContent>
<Form {...signInForm}>
<form
onSubmit={signInForm.handleSubmit(handleSignIn)}
className='flex flex-col space-y-8'
>
<FormField
control={signInForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signInForm.control}
name='password'
render={({ field }) => (
<FormItem>
<div className='flex justify-between'>
<FormLabel className='text-xl'>Password</FormLabel>
<Link href='/forgot-password'>
Forgot Password?
</Link>
</div>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing in...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Sign In
</SubmitButton>
</form>
</Form>
<div className='flex justify-center'>
<div className='mx-auto my-2.5 flex w-1/4 flex-row items-center justify-center'>
<Separator className='mr-3 py-0.5' />
<span className='text-lg font-semibold'>or</span>
<Separator className='ml-3 py-0.5' />
</div>
</div>
<div className='mt-3 flex justify-center'>
<GibsAuthSignInButton />
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='signUp'>
<Card className='bg-card/50 min-w-xs sm:min-w-sm'>
<CardContent>
<Form {...signUpForm}>
<form
onSubmit={signUpForm.handleSubmit(handleSignUp)}
className='flex flex-col space-y-8'
>
<FormField
control={signUpForm.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Name</FormLabel>
<FormControl>
<Input
type='text'
placeholder='Full Name'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm your password'
{...field}
/>
</FormControl>
<div className='flex w-full flex-col items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='mx-auto w-2/3 text-xl font-semibold'
>
Sign Up
</SubmitButton>
</form>
</Form>
<div className='my-auto flex w-2/3 justify-center'>
<div className='my-2.5 flex w-1/3 flex-row items-center'>
<Separator className='mr-3 py-0.5' />
<span className='text-lg font-semibold'>or</span>
<Separator className='ml-3 py-0.5' />
</div>
</div>
<div className='mt-3 flex justify-center'>
<GibsAuthSignInButton type='signUp' />
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</Card>
</div>
);
};
export default SignIn;
-82
View File
@@ -1,82 +0,0 @@
'use client';
import type { Metadata, Viewport } from 'next';
import NextError from 'next/error';
import { Geist, Geist_Mono } from 'next/font/google';
import '@/app/styles.css';
import { useEffect } from 'react';
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import { ConvexClientProvider } from '@/components/providers';
import { env } from '@/env';
import { generateMetadata } from '@/lib/metadata';
import * as Sentry from '@sentry/nextjs';
import PlausibleProvider from 'next-plausible';
import { Button, ThemeProvider, Toaster } from '@gib/ui';
export const metadata: Metadata = generateMetadata();
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' },
],
};
const geistSans = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
});
const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
});
interface GlobalErrorProps {
error: Error & { digest?: string };
reset?: () => void;
}
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<PlausibleProvider
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
<main className='flex min-h-screen flex-col items-center'>
<Header />
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try Again</Button>
)}
<Toaster />
<Footer />
</main>
<main className='flex min-h-[90vh] flex-col items-center'>
<Toaster />
</main>
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
);
};
export default GlobalError;
-70
View File
@@ -1,70 +0,0 @@
import type { Metadata, Viewport } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { env } from '@/env';
import '@/app/styles.css';
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import { ConvexClientProvider } from '@/components/providers';
import { generateMetadata } from '@/lib/metadata';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import PlausibleProvider from 'next-plausible';
import { ThemeProvider, Toaster } from '@gib/ui';
export const metadata: Metadata = generateMetadata();
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' },
],
};
const geistSans = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
});
const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
});
const RootLayout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
<div className='flex min-h-screen flex-col'>
<Header />
{children}
<Footer />
</div>
<Toaster />
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
</ConvexAuthNextjsServerProvider>
);
};
export default RootLayout;
-12
View File
@@ -1,12 +0,0 @@
import { CTA, Features, Hero, TechStack } from '@/components/landing';
export default function Home() {
return (
<main className='flex min-h-screen flex-col'>
<Hero />
<Features />
<TechStack />
<CTA />
</main>
);
}
-31
View File
@@ -1,31 +0,0 @@
export const CTA = () => (
<section className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-4xl'>
<div className='border-border/40 from-muted/50 to-muted/30 rounded-2xl border bg-linear-to-br p-8 text-center md:p-12'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
Ready to Build Something Amazing?
</h2>
<p className='text-muted-foreground mb-8 text-lg'>
Clone the repository and start building your next project with
everything pre-configured.
</p>
{/* Quick Start Command */}
<div className='mt-12'>
<p className='text-muted-foreground mb-3 text-sm font-medium'>
Quick Start
</p>
<div className='border-border/40 bg-background mx-auto max-w-2xl rounded-lg border p-4'>
<code className='text-sm'>
git clone https://git.gbrown.org/gib/convex-monorepo.git
<br />
cd convex-monorepo
<br />
bun i
</code>
</div>
</div>
</div>
</div>
</section>
);
@@ -1,90 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui';
const features = [
{
title: 'Turborepo',
description:
'Efficient build system with intelligent caching. Share code between web and mobile apps seamlessly.',
icon: '⚡',
},
{
title: 'Self-Hosted Convex',
description:
'Complete control over your data with self-hosted Convex backend. No vendor lock-in, deploy anywhere.',
icon: '🏠',
},
{
title: 'Next.js 16 + Expo',
description:
'Modern Next.js 16 with App Router for web, Expo 54 for mobile. One codebase, multiple platforms.',
icon: '📱',
},
{
title: 'Type-Safe Backend',
description:
'Fully type-safe queries and mutations with Convex. Auto-generated TypeScript types for the entire API.',
icon: '🔒',
},
{
title: 'Authentication Included',
description:
'OAuth with Authentik + custom password auth with email verification. Production-ready auth out of the box.',
icon: '🔐',
},
{
title: 'Real-time Updates',
description:
'Built-in real-time subscriptions with Convex reactive queries. No WebSocket configuration needed.',
icon: '⚡',
},
{
title: 'shadcn/ui Components',
description:
'Beautiful, accessible components from shadcn/ui. Customizable with Tailwind CSS v4.',
icon: '🎨',
},
{
title: 'Docker Ready',
description:
'Production Docker setup included. Deploy to any server with docker-compose up.',
icon: '🐳',
},
{
title: 'Developer Experience',
description:
'Hot reload, TypeScript strict mode, ESLint, Prettier, and Bun for blazing fast installs.',
icon: '⚙️',
},
];
export const Features = () => (
<section id='features' className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
{/* Section Header */}
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Everything You Need to Ship Fast
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
A complete monorepo template with all the tools and patterns you need
for production-ready applications.
</p>
</div>
{/* Features Grid */}
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-3'>
{features.map((feature) => (
<Card key={feature.title} className='border-border/40'>
<CardHeader className='flex items-center gap-2'>
<div className='mb-2 text-3xl'>{feature.icon}</div>
<CardTitle className='text-xl'>{feature.title}</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground'>{feature.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
-126
View File
@@ -1,126 +0,0 @@
import { Kanit } from 'next/font/google';
import Image from 'next/image';
import Link from 'next/link';
import { Button } from '@gib/ui';
const kanitSans = Kanit({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
});
export const Hero = () => (
<section className='container mx-auto px-4 py-24 md:py-32 lg:py-40'>
<div className='mx-auto flex max-w-5xl flex-col items-center gap-8 text-center'>
{/* Badge */}
<div className='border-border/40 bg-muted/50 inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium'>
<span className='mr-2'>🚀</span>
<span>Production-ready monorepo template</span>
</div>
{/* Heading */}
<h1 className='from-foreground to-foreground/70 bg-linear-to-br bg-clip-text text-4xl font-bold tracking-tight text-transparent sm:text-5xl md:text-6xl lg:text-7xl'>
Build Full-Stack Apps with{' '}
<span
className={`${kanitSans.className} to-accent-foreground bg-linear-to-r from-[#281A65] via-[#363354] bg-clip-text text-transparent sm:text-6xl lg:text-7xl xl:text-8xl dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]`}
>
convex monorepo
</span>
</h1>
{/* Description */}
<p className='text-muted-foreground max-w-2xl text-lg md:text-xl'>
A Turborepo starter with Next.js, Expo, and self-hosted Convex. Ship web
and mobile apps faster with shared code, type-safe backend, and complete
control over your infrastructure.
</p>
{/* CTA Buttons */}
<div className='flex flex-col gap-3 sm:flex-row'>
<Button size='lg' variant='outline' asChild>
<Link
href='https://git.gbrown.org/gib/convex-monorepo'
target='_blank'
rel='noopener noreferrer'
>
<Image
src='/misc/gitea/gitea.svg'
alt='Gitea'
width={20}
height={20}
/>
View Source Code
</Link>
</Button>
</div>
{/* Features Quick List */}
<div className='text-muted-foreground mt-8 flex flex-wrap items-center justify-center gap-6 text-sm'>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>TypeScript</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Self-Hosted</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Real-time</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Auth Included</span>
</div>
</div>
</div>
</section>
);
@@ -1,4 +0,0 @@
export { Hero } from './hero';
export { Features } from './features';
export { TechStack } from './tech-stack';
export { CTA } from './cta';
@@ -1,77 +0,0 @@
const techStack = [
{
category: 'Frontend',
technologies: [
{ name: 'Next.js 16', description: 'React framework with App Router' },
{ name: 'Expo 54', description: 'React Native framework' },
{ name: 'React 19', description: 'Latest React with Server Components' },
{
name: 'Tailwind CSS v4',
description: 'Utility-first CSS framework',
},
{ name: 'shadcn/ui', description: 'Beautiful component library' },
],
},
{
category: 'Backend',
technologies: [
{ name: 'Convex', description: 'Self-hosted reactive backend' },
{
name: '@convex-dev/auth',
description: 'Multi-provider authentication',
},
{ name: 'UseSend', description: 'Self-hosted email service' },
{ name: 'File Storage', description: 'Built-in file uploads' },
],
},
{
category: 'Developer Tools',
technologies: [
{ name: 'Turborepo', description: 'High-performance build system' },
{ name: 'TypeScript', description: 'Type-safe development' },
{ name: 'Bun', description: 'Fast package manager & runtime' },
{ name: 'ESLint + Prettier', description: 'Code quality tools' },
{ name: 'Docker', description: 'Containerized deployment' },
],
},
];
export const TechStack = () => (
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
{/* Section Header */}
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Modern Tech Stack
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
Built with the latest and greatest tools for maximum productivity
and performance.
</p>
</div>
{/* Tech Stack Grid */}
<div className='grid gap-12 md:grid-cols-3'>
{techStack.map((stack) => (
<div key={stack.category}>
<h3 className='mb-6 text-xl font-semibold'>{stack.category}</h3>
<ul className='space-y-4'>
{stack.technologies.map((tech) => (
<li key={tech.name}>
<div className='text-foreground font-medium'>
{tech.name}
</div>
<div className='text-muted-foreground text-sm'>
{tech.description}
</div>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
</section>
);
@@ -1,41 +0,0 @@
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'react';
import Image from 'next/image';
import { useAuthActions } from '@convex-dev/auth/react';
import type { buttonVariants } from '@gib/ui';
import { Button } from '@gib/ui';
interface Props {
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
type?: 'signIn' | 'signUp';
}
export const GibsAuthSignInButton = ({
buttonProps,
type = 'signIn',
}: Props) => {
const { signIn } = useAuthActions();
return (
<Button
size='lg'
onClick={() => signIn('authentik')}
className='text-lg font-semibold'
{...buttonProps}
>
<div className='my-auto flex flex-row space-x-1'>
<Image
src={'/misc/auth/gibs-auth-logo.png'}
className=''
alt="Gib's Auth"
width={30}
height={30}
/>
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with Gib&apos;s Auth</p>
</div>
</Button>
);
};
@@ -1 +0,0 @@
export { GibsAuthSignInButton } from './gibs-auth';
@@ -1,218 +0,0 @@
'use client';
import type { Preloaded } from 'convex/react';
import type { ChangeEvent } from 'react';
import { useRef, useState } from 'react';
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
import { api } from '@gib/backend/convex/_generated/api.js';
import {
Avatar,
AvatarImage,
BasedAvatar,
Button,
CardContent,
ImageCrop,
ImageCropApply,
ImageCropContent,
Input,
} from '@gib/ui';
interface AvatarUploadProps {
preloadedUser: Preloaded<typeof api.auth.getUser>;
}
const dataUrlToBlob = async (
dataUrl: string,
): Promise<{ blob: Blob; type: string }> => {
const re = /^data:([^;,]+)[;,]/;
const m = re.exec(dataUrl);
const type = m?.[1] ?? 'image/png';
const res = await fetch(dataUrl);
const blob = await res.blob();
return { blob, type };
};
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const user = usePreloadedQuery(preloadedUser);
const [isUploading, setIsUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [croppedImage, setCroppedImage] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUser = useMutation(api.auth.updateUser);
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file.');
if (inputRef.current) inputRef.current.value = '';
return;
}
setSelectedFile(file);
setCroppedImage(null);
};
const handleReset = () => {
setSelectedFile(null);
setCroppedImage(null);
if (inputRef.current) inputRef.current.value = '';
};
const handleSave = async () => {
if (!croppedImage) {
toast.error('Please apply a crop first.');
return;
}
setIsUploading(true);
try {
const { blob, type } = await dataUrlToBlob(croppedImage);
const postUrl = await generateUploadUrl();
const result = await fetch(postUrl, {
method: 'POST',
headers: { 'Content-Type': type },
body: blob,
});
if (!result.ok) {
const msg = await result.text().catch(() => 'Upload failed.');
throw new Error(msg);
}
const uploadResponse = (await result.json()) as {
storageId: Id<'_storage'>;
};
await updateUser({ image: uploadResponse.storageId });
toast.success('Profile picture updated.');
handleReset();
} catch (error) {
console.error('Upload failed:', error);
toast.error('Upload failed. Please try again.');
} finally {
setIsUploading(false);
}
};
return (
<CardContent>
<div className='flex flex-col items-center gap-4'>
{/* Current avatar + trigger (hidden when cropping) */}
{!selectedFile && (
<div
className='group relative cursor-pointer'
onClick={() => inputRef.current?.click()}
>
<BasedAvatar
src={currentImageUrl ?? undefined}
fullName={user?.name}
className='h-42 w-42 text-6xl font-semibold'
userIconProps={{ size: 100 }}
/>
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-black/0 transition-all group-hover:bg-black/50'>
<Upload
className='text-white opacity-0 transition-opacity group-hover:opacity-100'
size={24}
/>
</div>
<div className='absolute inset-1 flex items-end justify-end transition-all'>
<Pencil
className='text-white opacity-100 transition-opacity group-hover:opacity-0'
size={24}
/>
</div>
</div>
)}
{/* File input (hidden) */}
<Input
ref={inputRef}
id='avatar-upload'
type='file'
accept='image/*'
className='hidden'
onChange={handleFileChange}
disabled={isUploading}
/>
{/* Crop UI */}
{selectedFile && !croppedImage && (
<div className='flex flex-col items-center gap-3'>
<ImageCrop
aspect={1}
circularCrop
file={selectedFile}
maxImageSize={3 * 1024 * 1024} // 3MB guard
onCrop={setCroppedImage}
>
<ImageCropContent className='max-w-sm' />
<div className='flex items-center gap-2'>
<Button size='icon' variant='outline'>
<ImageCropApply className='h-full w-full scale-150' />
</Button>
<Button onClick={handleReset} size='icon' variant='destructive'>
<XIcon className='scale-150' />
</Button>
</div>
</ImageCrop>
</div>
)}
{/* Cropped preview + actions */}
{croppedImage && (
<div className='flex flex-col items-center gap-3'>
<Avatar className='h-42 w-42'>
<AvatarImage alt='Cropped preview' src={croppedImage} />
</Avatar>
<div className='flex items-center gap-1'>
<Button
onClick={handleSave}
disabled={isUploading}
variant='secondary'
className='px-4'
>
{isUploading ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
Saving...
</span>
) : (
'Save Avatar'
)}
</Button>
<Button
onClick={handleReset}
size='icon'
type='button'
variant='destructive'
>
<XIcon className='size-4' />
</Button>
</div>
</div>
)}
{/* Uploading indicator */}
{isUploading && !croppedImage && (
<div className='mt-2 flex items-center text-sm text-gray-500'>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Uploading...
</div>
)}
</div>
</CardContent>
);
};
@@ -1,16 +0,0 @@
'use client';
import { CardDescription, CardHeader, CardTitle } from '@gib/ui';
const ProfileHeader = () => {
return (
<CardHeader>
<CardTitle className='text-xl'>Account Settings</CardTitle>
<CardDescription>
Update your profile information and manage your account preferences
</CardDescription>
</CardHeader>
);
};
export { ProfileHeader };
@@ -1,5 +0,0 @@
export { AvatarUpload } from './avatar-upload';
export { ProfileHeader } from './header';
export { ResetPasswordForm } from './reset-password';
export { SignOutForm } from './sign-out';
export { UserInfoForm } from './user-info';
@@ -1,191 +0,0 @@
'use client';
import type { Preloaded } from 'convex/react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction, usePreloadedQuery } from 'convex/react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { api } from '@gib/backend/convex/_generated/api.js';
import { PASSWORD_MAX, PASSWORD_MIN, PASSWORD_REGEX } from '@gib/backend/types';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
SubmitButton,
} from '@gib/ui';
const formSchema = z
.object({
currentPassword: z.string().regex(PASSWORD_REGEX, {
message: 'Incorrect current password. Does not meet requirements.',
}),
newPassword: z
.string()
.min(PASSWORD_MIN, {
message: 'New password must be at least 8 characters.',
})
.max(PASSWORD_MAX, {
message: 'New password must be less than 100 characters.',
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string(),
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: 'New password must be different from current password.',
path: ['newPassword'],
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});
interface ResetFormProps {
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
}
export const ResetPasswordForm = ({ preloadedProvider }: ResetFormProps) => {
const userProvider = usePreloadedQuery(preloadedProvider);
const [loading, setLoading] = useState(false);
const changePassword = useAction(api.auth.updateUserPassword);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
setLoading(true);
try {
const result = await changePassword({
currentPassword: values.currentPassword,
newPassword: values.newPassword,
});
if (result.success) {
form.reset();
toast.success('Password updated successfully.');
}
} catch (error) {
console.error('Error updating password:', error);
toast.error('Error updating password.');
} finally {
setLoading(false);
}
};
// Only show password reset for email/password auth users
if (userProvider !== 'email') {
return null;
}
return (
<>
<Separator />
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='currentPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
placeholder='Enter current password'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='newPassword'
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
placeholder='Enter new password'
/>
</FormControl>
<FormDescription>
Must be at least 8 characters with uppercase, lowercase,
number, and symbol
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
placeholder='Confirm new password'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end pt-2'>
<SubmitButton disabled={loading} pendingText='Updating...'>
Update Password
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};
@@ -1,53 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { LogOut } from 'lucide-react';
import {
Button,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@gib/ui';
export const SignOutForm = () => {
const { signOut } = useAuthActions();
const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false);
const handleSignOut = async () => {
setIsSigningOut(true);
try {
await signOut();
router.push('/');
} catch (error) {
console.error('Sign out error:', error);
setIsSigningOut(false);
}
};
return (
<>
<CardHeader>
<CardTitle>Sign Out</CardTitle>
<CardDescription>
End your current session and return to the home page
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant='destructive'
className='w-full'
onClick={handleSignOut}
disabled={isSigningOut}
>
<LogOut className='mr-2 h-4 w-4' />
{isSigningOut ? 'Signing Out...' : 'Sign Out'}
</Button>
</CardContent>
</>
);
};
@@ -1,171 +0,0 @@
'use client';
import type { Preloaded } from 'convex/react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, usePreloadedQuery } from 'convex/react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { api } from '@gib/backend/convex/_generated/api.js';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
SubmitButton,
} from '@gib/ui';
const formSchema = z.object({
name: z
.string()
.trim()
.min(5, {
message: 'Full name is required & must be at least 5 characters.',
})
.max(50, {
message: 'Full name must be less than 50 characters.',
}),
email: z.email({
message: 'Please enter a valid email address.',
}),
});
interface UserInfoFormProps {
preloadedUser: Preloaded<typeof api.auth.getUser>;
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
}
export const UserInfoForm = ({
preloadedUser,
preloadedProvider,
}: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const userProvider = usePreloadedQuery(preloadedProvider);
const providerMap: Record<string, string> = {
unknown: 'Provider',
authentik: "Gib's Auth",
};
const [loading, setLoading] = useState(false);
const updateUser = useMutation(api.auth.updateUser);
const initialValues = useMemo<z.infer<typeof formSchema>>(
() => ({
name: user?.name ?? '',
email: user?.email ?? '',
}),
[user?.name, user?.email],
);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: initialValues,
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
if (!user) {
toast.error('User not found.');
return;
}
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
const patch: Partial<{
name: string;
email: string;
}> = {};
if (name !== (user.name ?? '')) patch.name = name;
if (email !== (user.email ?? '')) patch.email = email;
if (Object.keys(patch).length === 0) {
toast.info('No changes to save.');
return;
}
setLoading(true);
try {
await updateUser(patch);
form.reset(patch);
toast.success('Profile updated successfully.');
} catch (error) {
console.error(error);
toast.error('Error updating profile.');
} finally {
setLoading(false);
}
};
return (
<>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>Update your name and email address</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} placeholder='John Doe' />
</FormControl>
<FormDescription>Your public display name</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
type='email'
placeholder='john@example.com'
disabled={userProvider !== 'password'}
/>
</FormControl>
{userProvider === 'password' ? (
<FormDescription>
Your email address for account notifications
</FormDescription>
) : (
<FormDescription>
Email is managed through your{' '}
{providerMap[userProvider ?? 'unknown']} account
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end pt-2'>
<SubmitButton disabled={loading} pendingText='Saving...'>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};
@@ -1,120 +0,0 @@
import { Kanit } from 'next/font/google';
import Link from 'next/link';
const kanitSans = Kanit({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
});
export default function Footer() {
return (
<footer className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-12'>
<div className='grid gap-8 md:grid-cols-4'>
{/* Brand */}
<div className='md:col-span-2'>
<h3 className={`mb-2 text-3xl font-bold ${kanitSans.className}`}>
convex monorepo
</h3>
<p className='text-muted-foreground text-sm'>
A production-ready Turborepo starter with Next.js, Expo, and a
self-hosted Convex backend, including Convex Auth with a custom
useSend email provider to ensure everything can be self-hosted.
Built for developers who want complete control without sacrificing
ease of use.
</p>
</div>
{/* Links */}
<div>
<h4 className='mb-4 text-sm font-semibold'>Resources</h4>
<ul className='space-y-2 text-sm'>
<li>
<Link
href='https://git.gbrown.org/gib/convex-monorepo'
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Gitea Repository
</Link>
</li>
<li>
<Link
href='https://docs.convex.dev'
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Convex Documentation
</Link>
</li>
<li>
<Link
href='https://turbo.build'
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Turborepo
</Link>
</li>
</ul>
</div>
{/* Tech */}
<div>
<h4 className='mb-4 text-sm font-semibold'>Built With</h4>
<ul className='space-y-2 text-sm'>
<li>
<Link
href='https://nextjs.org'
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Next.js
</Link>
</li>
<li>
<Link
href='https://expo.dev'
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Expo
</Link>
</li>
<li>
<Link
href='https://ui.shadcn.com'
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground transition-colors'
>
shadcn/ui
</Link>
</li>
</ul>
</div>
</div>
{/* Bottom */}
<div className='border-border/40 text-muted-foreground mt-12 border-t pt-8 text-center text-sm'>
<p>
Built by{' '}
<Link
href='https://gbrown.org'
target='_blank'
rel='noopener noreferrer'
className='hover:text-foreground font-medium transition-colors'
>
Gib.
</Link>
</p>
</div>
</div>
</footer>
);
}
@@ -1,91 +0,0 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react';
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
import { api } from '@gib/backend/convex/_generated/api.js';
import {
BasedAvatar,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@gib/ui';
export const AvatarDropdown = () => {
const router = useRouter();
const { isLoading, isAuthenticated } = useConvexAuth();
const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, {});
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
);
if (isLoading) {
return (
<div className='flex items-center gap-2'>
<div className='bg-muted h-8 w-16 animate-pulse rounded-md' />
<div className='bg-muted h-9 w-9 animate-pulse rounded-full' />
</div>
);
}
if (!isAuthenticated) {
return (
<div className='flex items-center gap-2'>
<Button size='sm' asChild>
<Link href='/sign-in'>Sign In</Link>
</Button>
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger>
<BasedAvatar
src={currentImageUrl}
fullName={user?.name}
className='h-9 w-9'
fallbackProps={{ className: 'text-sm font-semibold' }}
userIconProps={{ size: 20 }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{(user?.name ?? user?.email) && (
<>
<DropdownMenuLabel className='text-center font-bold'>
{user.name?.trim() ?? user.email?.trim()}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href='/profile' className='w-full cursor-pointer'>
Edit Profile
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<button
onClick={() =>
void signOut().then(() => {
router.push('/');
})
}
className='w-full cursor-pointer'
>
Sign Out
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -1,22 +0,0 @@
'use client';
import type { ThemeToggleProps } from '@gib/ui';
import { ThemeToggle } from '@gib/ui';
import { AvatarDropdown } from './AvatarDropdown';
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
return (
<div className='flex items-center gap-3'>
<ThemeToggle
size={1.1}
buttonProps={{
variant: 'ghost',
size: 'sm',
...themeToggleProps?.buttonProps,
}}
/>
<AvatarDropdown />
</div>
);
};
@@ -1,72 +0,0 @@
import type { ComponentProps } from 'react';
import { Kanit } from 'next/font/google';
import Image from 'next/image';
import Link from 'next/link';
import { Coffee, Server, Wrench } from 'lucide-react';
import { Controls } from './controls';
const kanitSans = Kanit({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
});
export default function Header(headerProps: ComponentProps<'header'>) {
return (
<header
className='border-border/40 bg-background/95 supports-backdrop-filter:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur'
{...headerProps}
>
<div className='container mx-auto flex h-16 items-center justify-between px-4 md:px-6'>
{/* Logo */}
<Link
href='/'
className='to-accent-foreground flex items-center gap-2 bg-linear-to-r from-[#281A65] via-[#363354] bg-clip-text text-transparent transition-opacity hover:opacity-80 dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]'
>
<Image
src='/misc/convex/convex-symbol-white.svg'
alt='Convex Monorepo'
width={50}
height={50}
className='w-15 invert dark:invert-0'
/>
<span
className={`mb-3 hidden font-extrabold lg:inline lg:text-5xl ${kanitSans.className}`}
>
convex monorepo
</span>
</Link>
{/* Navigation */}
<nav className='hidden items-center gap-6 text-base font-medium md:flex'>
<Link
href='/#features'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Wrench width={18} height={18} />
Features
</Link>
<Link
href='/#tech-stack'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Server width={18} height={18} />
Stack
</Link>
<Link
href='https://git.gbrown.org/gib/convex-monorepo'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Coffee width={20} height={20} />
Repository
</Link>
</nav>
{/* Controls (Theme + Auth) */}
<Controls />
</div>
</header>
);
}
@@ -1,14 +0,0 @@
'use client';
import type { ReactNode } from 'react';
import { env } from '@/env';
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
import { ConvexReactClient } from 'convex/react';
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
<ConvexAuthNextjsProvider client={convex}>
{children}
</ConvexAuthNextjsProvider>
);
@@ -1 +0,0 @@
export { ConvexClientProvider } from './ConvexClientProvider';
-46
View File
@@ -1,46 +0,0 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod/v4';
export const env = createEnv({
server: {
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
SKIP_ENV_VALIDATION: z.boolean().default(false),
SENTRY_AUTH_TOKEN: z.string(),
CI: z.boolean().default(false),
},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_SITE_URL: z.url(),
NEXT_PUBLIC_CONVEX_URL: z.url(),
NEXT_PUBLIC_PLAUSIBLE_URL: z.url(),
NEXT_PUBLIC_SENTRY_DSN: z.string(),
NEXT_PUBLIC_SENTRY_URL: z.string(),
NEXT_PUBLIC_SENTRY_ORG: z.string(),
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG,
NEXT_PUBLIC_SENTRY_PROJECT_NAME:
process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
});
-27
View File
@@ -1,27 +0,0 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import { env } from '@/env';
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
integrations: [
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
Sentry.feedbackIntegration({
colorScheme: 'system',
}),
],
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true,
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
tracesSampleRate: 1,
enableLogs: true,
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
debug: false,
});
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
-7
View File
@@ -1,7 +0,0 @@
import type { Instrumentation } from 'next';
import * as Sentry from '@sentry/nextjs';
export const register = async () => await import('./sentry.server.config');
export const onRequestError: Instrumentation.onRequestError = (...args) => {
Sentry.captureRequestError(...args);
};
-129
View File
@@ -1,129 +0,0 @@
import type { Metadata } from 'next';
import * as Sentry from '@sentry/nextjs';
export const generateMetadata = (): Metadata => {
return {
title: {
template: '%s | Convex Monorepo',
default: 'Convex Monorepo',
},
description: 'A Convex Monorepo with Next.js & Expo',
applicationName: 'Convex Monorepo',
keywords:
'Convex Monorepo,T3 Template, Nextjs, ' +
'Tailwind, TypeScript, React, T3, Gib',
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
creator: 'Gib Brown',
publisher: 'Gib Brown',
formatDetection: {
email: false,
address: false,
telephone: false,
},
robots: {
index: true,
follow: true,
nocache: false,
googleBot: {
index: true,
follow: true,
noimageindex: false,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
icons: {
icon: [
{ url: '/favicon.png', type: 'image/png', sizes: 'any' },
{
url: '/favicon-light.png',
type: 'image/png',
sizes: 'any',
media: '(prefers-color-scheme: dark)',
},
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//},
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//media: '(prefers-color-scheme: dark)',
//},
],
//shortcut: [
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//},
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//media: '(prefers-color-scheme: dark)',
//},
//],
//apple: [
//{
//url: 'appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//},
//{
//url: 'appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//media: '(prefers-color-scheme: dark)',
//},
//],
//other: [
//{
//rel: 'apple-touch-icon-precomposed',
//url: '/appicon/icon-precomposed.png',
//type: 'image/png',
//sizes: '180x180',
//},
//],
},
other: {
...Sentry.getTraceData(),
},
//appleWebApp: {
//title: 'Convex Monorepo',
//statusBarStyle: 'black-translucent',
//startupImage: [
//'/icons/apple/splash-768x1004.png',
//{
//url: '/icons/apple/splash-1536x2008.png',
//media: '(device-width: 768px) and (device-height: 1024px)',
//},
//],
//},
verification: {
google: 'google',
yandex: 'yandex',
yahoo: 'yahoo',
},
category: 'technology',
/*
appLinks: {
ios: {
url: 'https://techtracker.gbrown.org/ios',
app_store_id: 'com.gbrown.techtracker',
},
android: {
package: 'https://techtracker.gbrown.org/android',
app_name: 'app_t3_template',
},
web: {
url: 'https://techtracker.gbrown.org',
should_fallback: true,
},
},
*/
};
};
-199
View File
@@ -1,199 +0,0 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// In-memory stores for tracking IPs (use Redis in production)
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
const ip404Attempts = new Map<string, { count: number; lastAttempt: number }>();
const bannedIPs = new Set<string>();
// Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [
// Your existing patterns
/web-inf/i,
/\.jsp/i,
/\.php/i,
/puttest/i,
/WEB-INF/i,
/\.xml$/i,
/perl/i,
/xampp/i,
/phpwebgallery/i,
/FileManager/i,
/standalonemanager/i,
/h2console/i,
/WebAdmin/i,
/login_form\.php/i,
/%2e/i,
/%u002e/i,
/\.%00/i,
/\.\./,
/lcgi/i,
// New patterns from your logs
/\/appliance\//i,
/bomgar/i,
/netburner-logo/i,
/\/ui\/images\//i,
/logon_merge/i,
/logon_t\.gif/i,
/login_top\.gif/i,
/theme1\/images/i,
/\.well-known\/acme-challenge\/.*\.jpg$/i,
/\.well-known\/pki-validation\/.*\.jpg$/i,
// Path traversal and system file access patterns
/\/etc\/passwd/i,
/\/etc%2fpasswd/i,
/\/etc%5cpasswd/i,
/\/\/+etc/i,
/\\\\+.*etc/i,
/%2f%2f/i,
/%5c%5c/i,
/\/\/+/,
/\\\\+/,
/%00/i,
/%23/i,
// Encoded path traversal attempts
/%2e%2e/i,
/%252e/i,
/%c0%ae/i,
/%c1%9c/i,
];
// Suspicious HTTP methods
const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH'];
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const MAX_ATTEMPTS = 10; // Max suspicious requests per window
const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
// 404 rate limiting settings
const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes
const MAX_404_ATTEMPTS = 10; // Max 404s before ban
const getClientIP = (request: NextRequest): string => {
const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip');
const cfConnectingIP = request.headers.get('cf-connecting-ip');
if (forwarded) return (forwarded.split(',')[0] ?? '').trim();
if (realIP) return realIP;
if (cfConnectingIP) return cfConnectingIP;
return request.headers.get('host') ?? 'unknown';
};
const isPathSuspicious = (pathname: string): boolean => {
return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
};
const isMethodSuspicious = (method: string): boolean => {
return SUSPICIOUS_METHODS.includes(method);
};
const updateIPAttempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ipAttempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
ipAttempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_ATTEMPTS) {
bannedIPs.add(ip);
ipAttempts.delete(ip);
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
return false;
};
const update404Attempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ip404Attempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_404_WINDOW) {
ip404Attempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_404_ATTEMPTS) {
bannedIPs.add(ip);
ip404Attempts.delete(ip);
console.log(
`🔨 IP ${ip} banned for excessive 404 requests (${attempts.count} in ${RATE_404_WINDOW / 1000}s)`,
);
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
return false;
};
export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => {
const { pathname } = request.nextUrl;
const method = request.method;
const ip = getClientIP(request);
// Check if IP is already banned
if (bannedIPs.has(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
const isSuspiciousPath = isPathSuspicious(pathname);
const isSuspiciousMethod = isMethodSuspicious(method);
// Handle suspicious activity
if (isSuspiciousPath || isSuspiciousMethod) {
const shouldBan = updateIPAttempts(ip);
if (shouldBan) {
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
return new NextResponse('Access denied - IP banned. Please fuck off.', {
status: 403,
});
}
return new NextResponse('Not Found', { status: 404 });
}
return null;
};
// Call this function when you detect a 404 response
export const handle404Response = (
request: NextRequest,
): NextResponse | null => {
const ip = getClientIP(request);
if (bannedIPs.has(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
const shouldBan = update404Attempts(ip);
if (shouldBan) {
return new NextResponse('Access denied - IP banned for excessive 404s.', {
status: 403,
});
}
return null;
};
-34
View File
@@ -1,34 +0,0 @@
import { banSuspiciousIPs } from '@/lib/proxy/ban-sus-ips';
import {
convexAuthNextjsMiddleware,
createRouteMatcher,
nextjsMiddlewareRedirect,
} from '@convex-dev/auth/nextjs/server';
const isSignInPage = createRouteMatcher(['/sign-in']);
const isProtectedRoute = createRouteMatcher(['/profile']);
export default convexAuthNextjsMiddleware(
async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/');
}
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/sign-in');
}
},
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
);
export const config = {
// The following matcher runs middleware on all routes
// except static assets.
matcher: [
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
'/((?!.*\\..*|_next).*)',
'/',
'/(api)(.*)',
],
};
-9
View File
@@ -1,9 +0,0 @@
import { env } from '@/env';
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1,
enableLogs: true,
debug: false,
});
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "eslint/config";
import { baseConfig, restrictEnvAccess } from "@acme/eslint-config/base";
import { nextjsConfig } from "@acme/eslint-config/nextjs";
import { reactConfig } from "@acme/eslint-config/react";
export default defineConfig(
{
ignores: [".next/**"],
},
baseConfig,
reactConfig,
nextjsConfig,
restrictEnvAccess,
);
+23
View File
@@ -0,0 +1,23 @@
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url);
// Import env files to validate at build time. Use jiti so we can load .ts files in here.
await jiti.import("./src/env");
/** @type {import("next").NextConfig} */
const config = {
/** Enables hot reloading for local packages without a build step */
transpilePackages: [
"@acme/api",
"@acme/auth",
"@acme/db",
"@acme/ui",
"@acme/validators",
],
/** We already do linting and typechecking as separate tasks in CI */
typescript: { ignoreBuildErrors: true },
};
export default config;
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@acme/nextjs",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@acme/backend": "workspace:*",
"@acme/ui": "workspace:*",
"@convex-dev/auth": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.8",
"convex": "workspace:*",
"next": "^16.0.0",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"superjson": "2.2.3",
"zod": "catalog:"
},
"devDependencies": {
"@acme/eslint-config": "workspace:*",
"@acme/prettier-config": "workspace:*",
"@acme/tailwind-config": "workspace:*",
"@acme/tsconfig": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"eslint": "catalog:",
"jiti": "^2.5.1",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "^1.4.0",
"typescript": "catalog:"
},
"prettier": "@acme/prettier-config"
}
+1
View File
@@ -0,0 +1 @@
export { default } from "@acme/tailwind-config/postcss-config";
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="258" height="198" viewBox="0 0 258 198" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_12)">
<path d="M165.269 24.0976L188.481 -0.000411987H0V24.0976H165.269Z" fill="black"/>
<path d="M163.515 95.3516L253.556 2.71059H220.74L145.151 79.7886L163.515 95.3516Z" fill="black"/>
<path d="M233.192 130.446C233.192 154.103 214.014 173.282 190.357 173.282C171.249 173.282 155.047 160.766 149.534 143.467L146.159 132.876L126.863 152.171L128.626 156.364C138.749 180.449 162.568 197.382 190.357 197.382C227.325 197.382 257.293 167.414 257.293 130.446C257.293 105.965 243.933 84.7676 224.49 73.1186L219.929 70.3856L202.261 88.2806L210.322 92.5356C223.937 99.7236 233.192 114.009 233.192 130.446Z" fill="black"/>
<path d="M87.797 191.697V44.6736H63.699V191.697H87.797Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1_12">
<rect width="258" height="198" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 923 B

@@ -0,0 +1,58 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { Button } from "@acme/ui/button";
import { auth, getSession } from "~/auth/server";
export async function AuthShowcase() {
const session = await getSession();
if (!session) {
return (
<form>
<Button
size="lg"
formAction={async () => {
"use server";
const res = await auth.api.signInSocial({
body: {
provider: "discord",
callbackURL: "/",
},
});
if (!res.url) {
throw new Error("No URL returned from signInSocial");
}
redirect(res.url);
}}
>
Sign in with Discord
</Button>
</form>
);
}
return (
<div className="flex flex-col items-center justify-center gap-4">
<p className="text-center text-2xl">
<span>Logged in as {session.user.name}</span>
</p>
<form>
<Button
size="lg"
formAction={async () => {
"use server";
await auth.api.signOut({
headers: await headers(),
});
redirect("/");
}}
>
Sign out
</Button>
</form>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
"use client";
import { useForm } from "@tanstack/react-form";
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import type { RouterOutputs } from "@acme/api";
import { CreatePostSchema } from "@acme/db/schema";
import { cn } from "@acme/ui";
import { Button } from "@acme/ui/button";
import {
Field,
FieldContent,
FieldError,
FieldGroup,
FieldLabel,
} from "@acme/ui/field";
import { Input } from "@acme/ui/input";
import { toast } from "@acme/ui/toast";
import { useTRPC } from "~/trpc/react";
export function CreatePostForm() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const createPost = useMutation(
trpc.post.create.mutationOptions({
onSuccess: async () => {
form.reset();
await queryClient.invalidateQueries(trpc.post.pathFilter());
},
onError: (err) => {
toast.error(
err.data?.code === "UNAUTHORIZED"
? "You must be logged in to post"
: "Failed to create post",
);
},
}),
);
const form = useForm({
defaultValues: {
content: "",
title: "",
},
validators: {
onSubmit: CreatePostSchema,
},
onSubmit: (data) => createPost.mutate(data.value),
});
return (
<form
className="w-full max-w-2xl"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<FieldGroup>
<form.Field
name="title"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
</FieldContent>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Title"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="content"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor={field.name}>Content</FieldLabel>
</FieldContent>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Content"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
</FieldGroup>
<Button type="submit">Create</Button>
</form>
);
}
export function PostList() {
const trpc = useTRPC();
const { data: posts } = useSuspenseQuery(trpc.post.all.queryOptions());
if (posts.length === 0) {
return (
<div className="relative flex w-full flex-col gap-4">
<PostCardSkeleton pulse={false} />
<PostCardSkeleton pulse={false} />
<PostCardSkeleton pulse={false} />
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/10">
<p className="text-2xl font-bold text-white">No posts yet</p>
</div>
</div>
);
}
return (
<div className="flex w-full flex-col gap-4">
{posts.map((p) => {
return <PostCard key={p.id} post={p} />;
})}
</div>
);
}
export function PostCard(props: {
post: RouterOutputs["post"]["all"][number];
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const deletePost = useMutation(
trpc.post.delete.mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries(trpc.post.pathFilter());
},
onError: (err) => {
toast.error(
err.data?.code === "UNAUTHORIZED"
? "You must be logged in to delete a post"
: "Failed to delete post",
);
},
}),
);
return (
<div className="bg-muted flex flex-row rounded-lg p-4">
<div className="grow">
<h2 className="text-primary text-2xl font-bold">{props.post.title}</h2>
<p className="mt-2 text-sm">{props.post.content}</p>
</div>
<div>
<Button
variant="ghost"
className="text-primary cursor-pointer text-sm font-bold uppercase hover:bg-transparent hover:text-white"
onClick={() => deletePost.mutate(props.post.id)}
>
Delete
</Button>
</div>
</div>
);
}
export function PostCardSkeleton(props: { pulse?: boolean }) {
const { pulse = true } = props;
return (
<div className="bg-muted flex flex-row rounded-lg p-4">
<div className="grow">
<h2
className={cn(
"bg-primary w-1/4 rounded-sm text-2xl font-bold",
pulse && "animate-pulse",
)}
>
&nbsp;
</h2>
<p
className={cn(
"mt-2 w-1/3 rounded-sm bg-current text-sm",
pulse && "animate-pulse",
)}
>
&nbsp;
</p>
</div>
</div>
);
}
@@ -0,0 +1,4 @@
import { auth } from "~/auth/server";
export const GET = auth.handler;
export const POST = auth.handler;
@@ -0,0 +1,46 @@
import type { NextRequest } from "next/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter, createTRPCContext } from "@acme/api";
import { auth } from "~/auth/server";
/**
* Configure basic CORS headers
* You should extend this to match your needs
*/
const setCorsHeaders = (res: Response) => {
res.headers.set("Access-Control-Allow-Origin", "*");
res.headers.set("Access-Control-Request-Method", "*");
res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST");
res.headers.set("Access-Control-Allow-Headers", "*");
};
export const OPTIONS = () => {
const response = new Response(null, {
status: 204,
});
setCorsHeaders(response);
return response;
};
const handler = async (req: NextRequest) => {
const response = await fetchRequestHandler({
endpoint: "/api/trpc",
router: appRouter,
req,
createContext: () =>
createTRPCContext({
auth: auth,
headers: req.headers,
}),
onError({ error, path }) {
console.error(`>>> tRPC Error on '${path}'`, error);
},
});
setCorsHeaders(response);
return response;
};
export { handler as GET, handler as POST };
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { cn } from "@acme/ui";
import { ThemeProvider, ThemeToggle } from "@acme/ui/theme";
import { Toaster } from "@acme/ui/toast";
import { env } from "~/env";
import { TRPCReactProvider } from "~/trpc/react";
import "~/app/styles.css";
export const metadata: Metadata = {
metadataBase: new URL(
env.VERCEL_ENV === "production"
? "https://turbo.t3.gg"
: "http://localhost:3000",
),
title: "Create T3 Turbo",
description: "Simple monorepo with shared backend for web & mobile apps",
openGraph: {
title: "Create T3 Turbo",
description: "Simple monorepo with shared backend for web & mobile apps",
url: "https://create-t3-turbo.vercel.app",
siteName: "Create T3 Turbo",
},
twitter: {
card: "summary_large_image",
site: "@jullerino",
creator: "@jullerino",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
};
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
});
export default function RootLayout(props: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"bg-background text-foreground min-h-screen font-sans antialiased",
geistSans.variable,
geistMono.variable,
)}
>
<ThemeProvider>
<TRPCReactProvider>{props.children}</TRPCReactProvider>
<div className="absolute right-4 bottom-4">
<ThemeToggle />
</div>
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { Suspense } from "react";
import { HydrateClient, prefetch, trpc } from "~/trpc/server";
import { AuthShowcase } from "./_components/auth-showcase";
import {
CreatePostForm,
PostCardSkeleton,
PostList,
} from "./_components/posts";
export default function HomePage() {
prefetch(trpc.post.all.queryOptions());
return (
<HydrateClient>
<main className="container h-screen py-16">
<div className="flex flex-col items-center justify-center gap-4">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
Create <span className="text-primary">T3</span> Turbo
</h1>
<AuthShowcase />
<CreatePostForm />
<div className="w-full max-w-2xl overflow-y-scroll">
<Suspense
fallback={
<div className="flex w-full flex-col gap-4">
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</div>
}
>
<PostList />
</Suspense>
</div>
</div>
</main>
</HydrateClient>
);
}
@@ -1,8 +1,8 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@gib/tailwind-config/theme';
@import "tailwindcss";
@import "tw-animate-css";
@import "@acme/tailwind-config/theme";
@source '../../../../packages/ui/src/*.{ts,tsx}';
@source "../../../../packages/ui/src/*.{ts,tsx}";
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));
+3
View File
@@ -0,0 +1,3 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
+29
View File
@@ -0,0 +1,29 @@
import "server-only";
import { cache } from "react";
import { headers } from "next/headers";
import { nextCookies } from "better-auth/next-js";
import { initAuth } from "@acme/auth";
import { env } from "~/env";
const baseUrl =
env.VERCEL_ENV === "production"
? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`
: env.VERCEL_ENV === "preview"
? `https://${env.VERCEL_URL}`
: "http://localhost:3000";
export const auth = initAuth({
baseUrl,
productionUrl: `https://${env.VERCEL_PROJECT_PRODUCTION_URL ?? "turbo.t3.gg"}`,
secret: env.AUTH_SECRET,
discordClientId: env.AUTH_DISCORD_ID,
discordClientSecret: env.AUTH_DISCORD_SECRET,
extraPlugins: [nextCookies()],
});
export const getSession = cache(async () =>
auth.api.getSession({ headers: await headers() }),
);
+39
View File
@@ -0,0 +1,39 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { vercel } from "@t3-oss/env-nextjs/presets-zod";
import { z } from "zod/v4";
import { authEnv } from "@acme/auth/env";
export const env = createEnv({
extends: [authEnv(), vercel()],
shared: {
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
},
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
POSTGRES_URL: z.url(),
},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
experimental__runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
});
+33
View File
@@ -0,0 +1,33 @@
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import SuperJSON from "superjson";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
shouldRedactErrors: () => {
// We should not catch Next.js server errors
// as that's how Next.js detects dynamic pages
// so we cannot redact them.
// Next.js also automatically redacts errors for us
// with better digests.
return false;
},
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
},
});
+70
View File
@@ -0,0 +1,70 @@
"use client";
import type { QueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import {
createTRPCClient,
httpBatchStreamLink,
loggerLink,
} from "@trpc/client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import SuperJSON from "superjson";
import type { AppRouter } from "@acme/api";
import { env } from "~/env";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
} else {
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
}
};
export const { useTRPC, TRPCProvider } = createTRPCContext<AppRouter>();
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [
loggerLink({
enabled: (op) =>
env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchStreamLink({
transformer: SuperJSON,
url: getBaseUrl() + "/api/trpc",
headers() {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{props.children}
</TRPCProvider>
</QueryClientProvider>
);
}
const getBaseUrl = () => {
if (typeof window !== "undefined") return window.location.origin;
if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`;
// eslint-disable-next-line no-restricted-properties
return `http://localhost:${process.env.PORT ?? 3000}`;
};

Some files were not shown because too many files have changed in this diff Show More