Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NextForge Encore.ts Starter Template #207

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
37 changes: 37 additions & 0 deletions ts/saas-nextforge-encore/.cursorrules.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# [PROJECT NAME]

## PROJECT DESCRIPTION
- [PROJECT DESCRIPTION - What is the goal of the project? What is the purpose of the project?]

## AI AGENT ROLE
- [AI AGENT ROLE - What is the role of the AI agent? What is the goal of the AI agent? Example ↴]
- You are a senior software engineer with great experience in [PROJECT LANGUAGE] and [PROJECT TECHNOLOGY].
- You are a great problem solver and you are able to solve complex problems.

## CODING STYLE AND STRUCTURE
- [How do you want the agent to write the code? What is the coding style and structure?]
- Prefer iteration and modularization over code duplication
- Use descriptive variable names with auxiliary verbs
- Write concise, technical TypeScript code with accurate examples

## Error Handling
- [How do you want the agent to handle errors?]
- Implement proper error boundaries
- Log errors appropriately for debugging
- Provide user-friendly error messages
- Handle network failures gracefully

## Testing
- [How do you want the agent to handle testing?]
- Write unit tests for utilities and components
- Implement E2E tests for critical flows
- Test across different Chrome versions
- Test memory usage and performance

## Security
- [How do you want the agent to handle security?]
- Implement Content Security Policy
- Sanitize user inputs
- Handle sensitive data properly
- Follow Chrome extension security best practices
- Implement proper CORS handling
128 changes: 128 additions & 0 deletions ts/saas-nextforge-encore/.github/CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct

## Our Pledge

We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.

## Our Standards

Examples of behavior that contributes to a positive environment for our
community include:

* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community

Examples of unacceptable behavior include:

* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting

## Enforcement Responsibilities

Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.

Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.

## Scope

This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
haydenbleasel.com/contact.
All complaints will be reviewed and investigated promptly and fairly.

All community leaders are obligated to respect the privacy and security of the
reporter of any incident.

## Enforcement Guidelines

Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:

### 1. Correction

**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.

**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.

### 2. Warning

**Community Impact**: A violation through a single incident or series
of actions.

**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.

### 3. Temporary Ban

**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.

**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.

### 4. Permanent Ban

**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.

**Consequence**: A permanent ban from any sort of public interaction within
the community.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.

Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).

[homepage]: https://www.contributor-covenant.org

For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
31 changes: 31 additions & 0 deletions ts/saas-nextforge-encore/.github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**next-forge version**
I am using version ...

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
- OS: [e.g. MacOS]
- Browser [e.g. chrome v130, safari]
17 changes: 17 additions & 0 deletions ts/saas-nextforge-encore/.github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
26 changes: 26 additions & 0 deletions ts/saas-nextforge-encore/.github/copilot-instructions.md.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copilot Guidelines

This project <PROJECT_NAME> uses <TECH_STACK>.

## Project Structure
Structure of how project files are setup. Making changes to files should be in their respected file.
```
| App | Description |
|-----------|-----------------------------------------------------------------------------|
| api | Contains serverless functions designed to run separately from the main app e.g. webhooks and cron jobs. |
| app | The main application, featuring a shadcn/ui template. |
| docs | The documentation, which contains the documentation for the app e.g. guides and tutorials. |
| email | The email preview server from react.email. |
| storybook | The storybook, which contains the storybook for the app. |
| studio | Prisma Studio, which is a graphical editor for the database. |
| web | The website, featuring a twblocks template. |
```

## Nesting
- Avoid deeply nested code. Break down logic into smaller functions.
- Opening curly braces should be on the same line as the statement.

## Error Handling
- Always catch a specific error instead of a generic one.
- Log the error message and stack trace.

16 changes: 16 additions & 0 deletions ts/saas-nextforge-encore/.github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
version: 2
updates:

# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
open-pull-requests-limit: 10
schedule:
interval: "monthly"

# Maintain dependencies for npm
- package-ecosystem: "npm"
directory: "/"
open-pull-requests-limit: 10
schedule:
interval: "monthly"
24 changes: 24 additions & 0 deletions ts/saas-nextforge-encore/.github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Description

Please provide a brief description of the changes introduced in this pull request.

## Related Issues

Closes #<issue_number>

## Checklist

- [ ] My code follows the code style of this project.
- [ ] I have performed a self-review of my code.
- [ ] I have commented my code, particularly in hard-to-understand areas.
- [ ] I have updated the documentation, if necessary.
- [ ] I have added tests that prove my fix is effective or my feature works.
- [ ] New and existing tests pass locally with my changes.

## Screenshots (if applicable)

<!-- Add screenshots to help explain your changes, especially if this is a UI-related PR. -->

## Additional Notes

<!-- Add any additional information or context about the pull request here. -->
48 changes: 48 additions & 0 deletions ts/saas-nextforge-encore/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# Dependencies
node_modules
.pnp
.pnp.js

# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# Testing
coverage

# Turbo
.turbo

# Vercel
.vercel

# Build Outputs
.next/
out/
build
dist


# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Misc
.DS_Store
*.pem

# Sentry Config File
.env.sentry-build-plugin

# BaseHub
.basehub

# AI Rules
.cursorrules
.github/copilot-instructions.md
10 changes: 10 additions & 0 deletions ts/saas-nextforge-encore/.vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"recommendations": [
"biomejs.biome",
"bradlc.vscode-tailwindcss",
"Prisma.prisma",
"unifiedjs.vscode-mdx",
"mikestead.dotenv",
"christian-kohler.npm-intellisense"
]
}
62 changes: 62 additions & 0 deletions ts/saas-nextforge-encore/.vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev"
},
{
"name": "Next.js: debug client-side (app)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug client-side (web)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3001"
},
{
"name": "Next.js: debug client-side (api)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3002"
},
{
"name": "Next.js: debug client-side (email)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3003"
},
{
"name": "Next.js: debug client-side (app)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3004"
},
{
"name": "Next.js: debug client-side (studio)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3005"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithEdge",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
}
}
]
}
28 changes: 28 additions & 0 deletions ts/saas-nextforge-encore/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"emmet.showExpandedAbbreviation": "never",
"prettier.enable": false,
"typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.configFile": "frontend/packages/design-system/styles/globals.css"
}
6 changes: 6 additions & 0 deletions ts/saas-nextforge-encore/backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.encore
encore.gen.go
encore.gen.cue
/.encore
node_modules
/encore.gen
1 change: 1 addition & 0 deletions ts/saas-nextforge-encore/backend/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
119 changes: 119 additions & 0 deletions ts/saas-nextforge-encore/backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# URL Shortener Starter

This is an Encore starter for a URL Shortener. It has two API endpoints and a PostgreSQL database to store the URL IDs
and retrieve the full URL given an ID.

## Build from scratch with a tutorial

If you prefer to built it yourself, check out the [tutorial](https://encore.dev/docs/ts/tutorials/rest-api) to learn how to build this application from scratch.

## Prerequisites

**Install Encore:**
- **macOS:** `brew install encoredev/tap/encore`
- **Linux:** `curl -L https://encore.dev/install.sh | bash`
- **Windows:** `iwr https://encore.dev/install.ps1 | iex`

**Docker:**
1. [Install Docker](https://docker.com)
2. Start Docker

## Create app

Create a local app from this template:

```bash
encore app create my-app-name --example=ts/url-shortener
```

## Run app locally

Before running your application, make sure you have Docker installed and running. Then run this command from your application's root folder:

```bash
encore run
```

## Using the API

### url.shorten — Shortens a URL and adds it to the database

```bash
curl 'http://127.0.0.1:4000/url' -d '{"url":"https://google.com"}'
```

### url.get — Gets a URL from the database using a short ID

```bash
curl 'http://127.0.0.1:4000/url/:id'
```

### url.list — Lists all shortened URLs

```bash
curl 'http://127.0.0.1:4000/url'
```

## Open the developer dashboard

While `encore run` is running, open [http://localhost:9400](http://localhost:9400) to access Encore's [local developer dashboard](https://encore.dev/docs/ts/observability/dev-dash).

Here you can see API docs, make requests in the API explorer, and view traces of the responses.

## Using the API

To see that your app is running, you can ping the API to shorten a url.

```bash
curl 'http://localhost:4000/url' -d '{"url":"https://news.ycombinator.com"}'
```

When you ping the API, you will see traces and logs appearing in the local development dashboard: [http://localhost:9400](http://localhost:9400)

## Connecting to databases

You can connect to your databases via psql shell:

```bash
encore db shell <database-name> --env=local --superuser
```

Learn more in the [CLI docs](https://encore.dev/docs/ts/cli/cli-reference#database-management).

## Deployment

### Self-hosting

See the [self-hosting instructions](https://encore.dev/docs/ts/self-host/build) for how to use `encore build docker` to create a Docker image and configure it.

### Encore Cloud Platform

Deploy your application to a free staging environment in Encore's development cloud using `git push encore`:

```bash
git add -A .
git commit -m 'Commit message'
git push encore
```

You can also open your app in the [Cloud Dashboard](https://app.encore.dev) to integrate with GitHub, or connect your AWS/GCP account, enabling Encore to automatically handle cloud deployments for you.

## Link to GitHub

Follow these steps to link your app to GitHub:

1. Create a GitHub repo, commit and push the app.
2. Open your app in the [Cloud Dashboard](https://app.encore.dev).
3. Go to **Settings ➔ GitHub** and click on **Link app to GitHub** to link your app to GitHub and select the repo you just created.
4. To configure Encore to automatically trigger deploys when you push to a specific branch name, go to the **Overview** page for your intended environment. Click on **Settings** and then in the section **Branch Push** configure the **Branch name** and hit **Save**.
5. Commit and push a change to GitHub to trigger a deploy.

[Learn more in the docs](https://encore.dev/docs/platform/integrations/github)

## Testing

To run tests, configure the `test` command in your `package.json` to the test runner of your choice, and then use the command `encore test` from the CLI. The `encore test` command sets up all the necessary infrastructure in test mode before handing over to the test runner. [Learn more](https://encore.dev/docs/ts/develop/testing)

```bash
encore test
```
5 changes: 5 additions & 0 deletions ts/saas-nextforge-encore/backend/api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# API Service

## Overview

This service, named `api`, is designed to be the starting point for all API-related functionalities. As the service requirements grow larger, it can be split into separate services, each potentially using a clone of the same database. Encore makes such decisions very easy to implement.
22 changes: 22 additions & 0 deletions ts/saas-nextforge-encore/backend/api/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { PrismaClient } from '@prisma/client';
import { SQLDatabase } from "encore.dev/storage/sqldb";

// Define a database named 'encore_prisma_test', using the database migrations
// in the "./prisma/migrations" folder (where prisma will generate their migrations).
// Set `source` to `prisma` to let Encore know that the migrations are generated by Prisma.
const DB = new SQLDatabase("encore_prisma_test", {
migrations: {
path: "./prisma/migrations",
source: "prisma",
},
});

const prisma = new PrismaClient({
datasources: {
db: {
url: DB.connectionString,
},
},
});

export { prisma };
31 changes: 31 additions & 0 deletions ts/saas-nextforge-encore/backend/api/encore.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { APIError, api } from 'encore.dev/api';
import { Service } from 'encore.dev/service';
import * as auth from '~encore/auth';

interface APIResponse {
session: any;
user: any;
}

export const APITesting = api(
{
method: ['GET'],
path: '/api/me',
expose: true,
auth: true,
},
async (): Promise<{ data: APIResponse }> => {
const authData = auth.getAuthData();
if (!authData) {
throw APIError.unauthenticated('Unauthenticated');
}
return {
data: {
session: authData.session,
user: authData.user,
},
};
}
);

export default new Service('api');
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
138 changes: 138 additions & 0 deletions ts/saas-nextforge-encore/backend/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

datasource db {
provider = "postgresql"
url = "postgresql://encore-example-amk2:shadow-cv0o572lu9j08ktm1jd0@127.0.0.1:9500/encore_prisma_test?sslmode=disable"
relationMode = "prisma"
}
// TODO: replace the url below with the output from running
// encore db conn-uri encore_prisma_test --shadow
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}


model Page {
id Int @id @default(autoincrement())
email String @unique
name String?
}

model User {
id String @id
name String
email String
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
username String
displayUsername String
phoneNumber String?
phoneNumberVerified Boolean?
isAnonymous Boolean?
sessions Session[]
accounts Account[]
members Member[]
invitations Invitation[]
privateMetadata Json?
@@unique([email])
@@unique([username])
@@unique([phoneNumber])
@@map("user")
}

model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
activeOrganizationId String?
@@unique([token])
@@index([userId])
@@map("session")
}

model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
@@index([userId])
@@map("account")
}

model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}

model Organization {
id String @id
name String
slug String?
logo String?
createdAt DateTime
metadata String?
members Member[]
invitations Invitation[]
@@unique([slug])
@@map("organization")
}

model Member {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role String
createdAt DateTime
@@index([organizationId])
@@index([userId])
@@map("member")
}

model Invitation {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
email String
role String?
status String
expiresAt DateTime
inviterId String
user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@index([inviterId])
@@map("invitation")
}
3 changes: 3 additions & 0 deletions ts/saas-nextforge-encore/backend/auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
# Keep environment variables out of version control
.env
533 changes: 533 additions & 0 deletions ts/saas-nextforge-encore/backend/auth/better-auth.routes.ts

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions ts/saas-nextforge-encore/backend/auth/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { PrismaClient } from '@prisma/client';
import { SQLDatabase } from "encore.dev/storage/sqldb";

// Define a database named 'encore_prisma_test', using the database migrations
// in the "./prisma/migrations" folder (where prisma will generate their migrations).
// Set `source` to `prisma` to let Encore know that the migrations are generated by Prisma.
const DB = new SQLDatabase("encore_prisma_test", {
migrations: {
path: "./prisma/migrations",
source: "prisma",
},
});

const prisma = new PrismaClient({
datasources: {
db: {
url: DB.connectionString,
},
},
});

export { prisma };
91 changes: 91 additions & 0 deletions ts/saas-nextforge-encore/backend/auth/encore.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { apiKey, username } from 'better-auth/plugins';
import { encoreBetterAuth } from 'encore-better-auth';
import { currentRequest } from 'encore.dev';
import { APIError, Gateway, type Header, api } from 'encore.dev/api';
import { authHandler } from 'encore.dev/auth';
import { Service } from 'encore.dev/service';
import * as encoreAuth from '~encore/auth';
import { prisma } from './database.js';

// ensure to export auth.
export const auth = encoreBetterAuth({
currentRequest: currentRequest,
database: prismaAdapter(prisma, {
provider: 'sqlite', // or "mysql", "postgresql", ...etc
}),
emailAndPassword: {
enabled: true,
},
basePath: '/auth',
plugins: [
username(),
apiKey({
rateLimit: {
enabled: true,
timeWindow: 1000 * 60 * 60 * 24, // 1 day
maxRequests: 10, // 10 requests per day
},
}),
],
// socialProviders: {
// facebook: {
// clientId: "",
// clientSecret: "",
// },
// google: {
// clientId: "",
// clientSecret: "",
// },
// },
generateRoutes: true,
wrapResponse: true, // must be true.
});

// Encore will consider this directory and all its subdirectories as part of the "users" service.
// https://encore.dev/docs/ts/primitives/services
export default new Service('auth', {
middlewares: [...auth.middlewares],
});

interface AuthParams {
cookie: Header<'Cookie'>;
}

interface AuthData {
userID: string;
user: any;
session: any;
}

export const handler = authHandler<AuthParams, AuthData>(async (authdata) => {
return auth.getValidatedSession(authdata.cookie);
});

export const gateway = new Gateway({ authHandler: handler });

interface MeResponse {
session: any;
user: any;
}

export const me = api(
{
method: ['GET'],
path: '/me',
expose: true,
auth: true,
},
async (): Promise<{ data: MeResponse }> => {
const authData = encoreAuth.getAuthData();
if (!authData) {
throw APIError.unauthenticated('Unauthenticated');
}
return {
data: {
session: authData.session,
user: authData.user,
},
};
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
138 changes: 138 additions & 0 deletions ts/saas-nextforge-encore/backend/auth/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

datasource db {
provider = "postgresql"
url = "postgresql://encore-example-amk2:shadow-cv0o572lu9j08ktm1jd0@127.0.0.1:9500/encore_prisma_test?sslmode=disable"
relationMode = "prisma"
}
// TODO: replace the url below with the output from running
// encore db conn-uri encore_prisma_test --shadow
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}


model Page {
id Int @id @default(autoincrement())
email String @unique
name String?
}

model User {
id String @id
name String
email String
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
username String
displayUsername String
phoneNumber String?
phoneNumberVerified Boolean?
isAnonymous Boolean?
sessions Session[]
accounts Account[]
members Member[]
invitations Invitation[]
privateMetadata Json?
@@unique([email])
@@unique([username])
@@unique([phoneNumber])
@@map("user")
}

model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
activeOrganizationId String?
@@unique([token])
@@index([userId])
@@map("session")
}

model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
@@index([userId])
@@map("account")
}

model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}

model Organization {
id String @id
name String
slug String?
logo String?
createdAt DateTime
metadata String?
members Member[]
invitations Invitation[]
@@unique([slug])
@@map("organization")
}

model Member {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role String
createdAt DateTime
@@index([organizationId])
@@index([userId])
@@map("member")
}

model Invitation {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
email String
role String?
status String
expiresAt DateTime
inviterId String
user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@index([inviterId])
@@map("invitation")
}
4 changes: 4 additions & 0 deletions ts/saas-nextforge-encore/backend/encore.app
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"id": "forge-next-encore-bnvi",
"lang": "typescript"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PostHog } from 'posthog-node';

export const analytics = new PostHog('NEXT_PUBLIC_POSTHOG_KEY', {
host: 'NEXT_PUBLIC_POSTHOG_HOST',

// Don't batch events and flush immediately - we're running in a serverless environment
flushAt: 1,
flushInterval: 0,
});
1,361 changes: 1,361 additions & 0 deletions ts/saas-nextforge-encore/backend/package-lock.json

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions ts/saas-nextforge-encore/backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "api",
"private": true,
"version": "0.0.1",
"description": "NextForge Encore Backend",
"license": "MIT",
"type": "module",
"scripts": {
"test": "vitest",
"postinstall": "npx prisma generate --schema=auth/prisma/schema.prisma"
},
"devDependencies": {
"@types/node": "^20.5.7",
"typescript": "^5.2.2",
"prisma": "^5.22.0",
"vitest": "^2.1.4"
},
"dependencies": {
"encore.dev": "^1.46.6",
"@prisma/client": "^5.22.0",
"better-auth": "^1.2.3",
"encore-better-auth": "0.2.0",
"posthog-node": "^4.9.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.13.0"
}
}
1,357 changes: 1,357 additions & 0 deletions ts/saas-nextforge-encore/backend/pnpm-lock.yaml

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions ts/saas-nextforge-encore/backend/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
/* Basic Options */
"lib": ["ES2022"],
"target": "ES2022",
"module": "ES2022",
"types": ["node"],
"paths": {
"~encore/*": ["./encore.gen/*"]
},

/* Workspace Settings */
"composite": true,

/* Strict Type-Checking Options */
"strict": true,

/* Module Resolution Options */
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"sourceMap": true,

"declaration": true,

/* Advanced Options */
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}
7 changes: 7 additions & 0 deletions ts/saas-nextforge-encore/backend/url/encore.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Service } from "encore.dev/service";

// Encore will consider this directory and all its subdirectories as part of the "url" service.
// https://encore.dev/docs/ts/primitives/services

// The url service is used to shorten URLs.
export default new Service("url");
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE TABLE url (
id TEXT PRIMARY KEY,
original_url TEXT NOT NULL
);
10 changes: 10 additions & 0 deletions ts/saas-nextforge-encore/backend/url/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, test } from "vitest";
import { get, shorten } from "./url.js";

describe("shorten", () => {
test("getting a shortened url should give back the original", async () => {
const resp = await shorten({ url: "https://example.com" });
const url = await get({ id: resp.id });
expect(url.url).toBe("https://example.com");
});
});
60 changes: 60 additions & 0 deletions ts/saas-nextforge-encore/backend/url/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { api, APIError } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { randomBytes } from "node:crypto";

// 'url' database is used to store the URLs that are being shortened.
const db = new SQLDatabase("url", { migrations: "./migrations" });

interface URL {
id: string; // short-form URL id
url: string; // complete URL, in long form
}

interface ShortenParams {
url: string; // the URL to shorten
}

// shorten shortens a URL.
export const shorten = api(
{ expose: true, auth: false, method: "POST", path: "/url" },
async ({ url }: ShortenParams): Promise<URL> => {
const id = randomBytes(6).toString("base64url");
await db.exec`
INSERT INTO url (id, original_url)
VALUES (${id}, ${url})
`;
return { id, url };
}
);

// Get retrieves the original URL for the id.
export const get = api(
{ expose: true, auth: false, method: "GET", path: "/url/:id" },
async ({ id }: { id: string }): Promise<URL> => {
const row = await db.queryRow`
SELECT original_url FROM url WHERE id = ${id}
`;
if (!row) throw APIError.notFound("url not found");
return { id, url: row.original_url };
}
);

interface ListResponse {
urls: URL[];
}

// List retrieves all URLs.
export const list = api(
{ expose: false, method: "GET", path: "/url" },
async (): Promise<ListResponse> => {
const rows = db.query`
SELECT id, original_url
FROM url
`;
const urls: URL[] = [];
for await (const row of rows) {
urls.push({ id: row.id, url: row.original_url });
}
return { urls };
}
);
11 changes: 11 additions & 0 deletions ts/saas-nextforge-encore/backend/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import path from "path";

export default defineConfig({
resolve: {
alias: {
"~encore": path.resolve(__dirname, "./encore.gen"),
},
},
});
18 changes: 18 additions & 0 deletions ts/saas-nextforge-encore/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"extends": ["ultracite"],
"javascript": {
"globals": ["Liveblocks"]
},
"files": {
"ignore": [
"frontend/packages/design-system/components/ui/**",
"frontend/packages/design-system/lib/**",
"frontend/packages/design-system/hooks/**",
"frontend/packages/collaboration/config.ts",
"frontend/apps/docs/**/*.json",
"frontend/apps/email/.react-email/**",
"backend/**/better-auth.routes.ts"
]
}
}
25 changes: 25 additions & 0 deletions ts/saas-nextforge-encore/frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# next-forge

**Production-grade Turborepo template for Next.js apps.**

<div>
<img src="https://img.shields.io/npm/dy/next-forge" alt="" />
<img src="https://img.shields.io/npm/v/next-forge" alt="" />
<img src="https://img.shields.io/github/license/haydenbleasel/next-forge" alt="" />
</div>

[next-forge](https://github.com/haydenbleasel/next-forge) is a [Next.js](https://nextjs.org/) project boilerplate for modern web application. It is designed to be a comprehensive starting point for new apps, providing a solid, opinionated foundation with a minimal amount of configuration.

Clone the repo using:

```sh
npx next-forge@latest init
```

Then read the [docs](https://docs.next-forge.com) for more information.

<a href="https://github.com/haydenbleasel/next-forge/graphs/contributors">
<img src="https://contrib.rocks/image?repo=haydenbleasel/next-forge" />
</a>

Made with [contrib.rocks](https://contrib.rocks).
34 changes: 34 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Server
CLERK_SECRET_KEY=""
CLERK_WEBHOOK_SECRET=""
RESEND_FROM=""
DATABASE_URL=""
RESEND_TOKEN=""
STRIPE_SECRET_KEY=""
STRIPE_WEBHOOK_SECRET=""
BETTERSTACK_API_KEY=""
BETTERSTACK_URL=""
FLAGS_SECRET=""
ARCJET_KEY=""
SVIX_TOKEN=""
LIVEBLOCKS_SECRET=""
BASEHUB_TOKEN=""
VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3000"
KNOCK_API_KEY=""
KNOCK_FEED_CHANNEL_ID=""
KNOCK_SECRET_API_KEY=""

# Client
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"
NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up"
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL="/"
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL="/"
NEXT_PUBLIC_GA_MEASUREMENT_ID=""
NEXT_PUBLIC_KNOCK_API_KEY=""
NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID=""
NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST=""
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_WEB_URL="http://localhost:3001"
NEXT_PUBLIC_DOCS_URL="http://localhost:3004"
45 changes: 45 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

# prisma
.env

# react.email
.react-email

# Sentry
.sentryclirc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import Page from '../app/(unauthenticated)/sign-in/[[...sign-in]]/page';

test('Sign In Page', () => {
render(<Page />);
expect(
screen.getByRole('heading', {
level: 1,
name: 'Welcome back',
})
).toBeDefined();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import Page from '../app/(unauthenticated)/sign-up/[[...sign-up]]/page';

test('Sign Up Page', () => {
render(<Page />);
expect(
screen.getByRole('heading', {
level: 1,
name: 'Create an account',
})
).toBeDefined();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use client';

import { useOthers, useSelf } from '@repo/collaboration/hooks';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@repo/design-system/components/ui/avatar';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@repo/design-system/components/ui/tooltip';

type PresenceAvatarProps = {
info?: Liveblocks['UserMeta']['info'];
};

const PresenceAvatar = ({ info }: PresenceAvatarProps) => (
<Tooltip delayDuration={0}>
<TooltipTrigger>
<Avatar className="h-7 w-7 bg-secondary ring-1 ring-background">
<AvatarImage src={info?.avatar} alt={info?.name} />
<AvatarFallback className="text-xs">
{info?.name?.slice(0, 2)}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent collisionPadding={4}>
<p>{info?.name ?? 'Unknown'}</p>
</TooltipContent>
</Tooltip>
);

export const AvatarStack = () => {
const others = useOthers();
const self = useSelf();
const hasMoreUsers = others.length > 3;

return (
<div className="-space-x-1 flex items-center px-4">
{others.slice(0, 3).map(({ connectionId, info }) => (
<PresenceAvatar key={connectionId} info={info} />
))}

{hasMoreUsers && (
<PresenceAvatar
info={{
name: `+${others.length - 3}`,
color: 'var(--color-muted-foreground)',
}}
/>
)}

{self && <PresenceAvatar info={self.info} />}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';

import { getUsers } from '@/app/actions/users/get';
import { searchUsers } from '@/app/actions/users/search';
import { Room } from '@repo/collaboration/room';
import type { ReactNode } from 'react';

export const CollaborationProvider = ({
orgId,
children,
}: {
orgId: string;
children: ReactNode;
}) => {
const resolveUsers = async ({ userIds }: { userIds: string[] }) => {
const response = await getUsers(userIds);

if ('error' in response) {
throw new Error('Problem resolving users');
}

return response.data;
};

const resolveMentionSuggestions = async ({ text }: { text: string }) => {
const response = await searchUsers(text);

if ('error' in response) {
throw new Error('Problem resolving mention suggestions');
}

return response.data;
};

return (
<Room
id={`${orgId}:presence`}
authEndpoint="/api/collaboration/auth"
fallback={
<div className="px-3 text-muted-foreground text-xs">Loading...</div>
}
resolveUsers={resolveUsers}
resolveMentionSuggestions={resolveMentionSuggestions}
>
{children}
</Room>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use client';

import { useMyPresence, useOthers } from '@repo/collaboration/hooks';
import { useEffect } from 'react';

const Cursor = ({
name,
color,
x,
y,
}: {
name: string | undefined;
color: string;
x: number;
y: number;
}) => (
<div
className="pointer-events-none absolute top-0 left-0 z-[999] select-none transition-transform duration-100"
style={{
transform: `translateX(${x}px) translateY(${y}px)`,
}}
>
<svg
className="absolute top-0 left-0"
width="24"
height="36"
viewBox="0 0 24 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Cursor</title>
<path
d="M5.65376 12.3673H5.46026L5.31717 12.4976L0.500002 16.8829L0.500002 1.19841L11.7841 12.3673H5.65376Z"
fill={color}
/>
</svg>
<div
className="absolute top-4 left-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-white text-xs"
style={{
backgroundColor: color,
}}
>
{name}
</div>
</div>
);

export const Cursors = () => {
/**
* useMyPresence returns the presence of the current user and a function to update it.
* updateMyPresence is different than the setState function returned by the useState hook from React.
* You don't need to pass the full presence object to update it.
* See https://liveblocks.io/docs/api-reference/liveblocks-react#useMyPresence for more information
*/
const [_cursor, updateMyPresence] = useMyPresence();

/**
* Return all the other users in the room and their presence (a cursor position in this case)
*/
const others = useOthers();

useEffect(() => {
const onPointerMove = (event: PointerEvent) => {
// Update the user cursor position on every pointer move
updateMyPresence({
cursor: {
x: Math.round(event.clientX),
y: Math.round(event.clientY),
},
});
};

const onPointerLeave = () => {
// When the pointer goes out, set cursor to null
updateMyPresence({
cursor: null,
});
};

document.body.addEventListener('pointermove', onPointerMove);
document.body.addEventListener('pointerleave', onPointerLeave);

return () => {
document.body.removeEventListener('pointermove', onPointerMove);
document.body.removeEventListener('pointerleave', onPointerLeave);
};
}, [updateMyPresence]);

return others.map(({ connectionId, presence, info }) => {
if (!presence.cursor) {
return null;
}

return (
<Cursor
key={`cursor-${connectionId}`}
// connectionId is an integer that is incremented at every new connections
// Assigning a color with a modulo makes sure that a specific user has the same colors on every clients
color={info.color}
x={presence.cursor.x}
y={presence.cursor.y}
name={info?.name}
/>
);
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@repo/design-system/components/ui/breadcrumb';
import { Separator } from '@repo/design-system/components/ui/separator';
import { SidebarTrigger } from '@repo/design-system/components/ui/sidebar';
import { Fragment, type ReactNode } from 'react';

type HeaderProps = {
pages: string[];
page: string;
children?: ReactNode;
};

export const Header = ({ pages, page, children }: HeaderProps) => (
<header className="flex h-16 shrink-0 items-center justify-between gap-2">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
{pages.map((page, index) => (
<Fragment key={page}>
{index > 0 && <BreadcrumbSeparator className="hidden md:block" />}
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">{page}</BreadcrumbLink>
</BreadcrumbItem>
</Fragment>
))}
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>{page}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
{children}
</header>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client';

import { useAnalytics } from '@repo/analytics/posthog/client';
import { useUser } from '@repo/auth/client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';

export const PostHogIdentifier = () => {
const { user } = useUser();
const identified = useRef(false);
const pathname = usePathname();
const searchParams = useSearchParams();
const analytics = useAnalytics();

useEffect(() => {
// Track pageviews
if (pathname && analytics) {
let url = window.origin + pathname;
if (searchParams.toString()) {
url = `${url}?${searchParams.toString()}`;
}
analytics.capture('$pageview', {
$current_url: url,
});
}
}, [pathname, searchParams, analytics]);

useEffect(() => {
if (!user || identified.current) {
return;
}

analytics.identify(user.id, {
email: user.emailAddresses.at(0)?.emailAddress,
firstName: user.firstName,
lastName: user.lastName,
createdAt: user.createdAt,
avatar: user.imageUrl,
phoneNumber: user.phoneNumbers.at(0)?.phoneNumber,
});

identified.current = true;
}, [user, analytics]);

return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Button } from '@repo/design-system/components/ui/button';
import { Input } from '@repo/design-system/components/ui/input';
import { ArrowRightIcon, SearchIcon } from 'lucide-react';

export const Search = () => (
<form action="/search" className="flex items-center gap-2 px-4">
<div className="relative">
<div className="absolute top-px bottom-px left-px flex h-8 w-8 items-center justify-center">
<SearchIcon size={16} className="text-muted-foreground" />
</div>
<Input
type="text"
name="q"
placeholder="Search"
className="h-auto bg-background py-1.5 pr-3 pl-8 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="absolute top-px right-px bottom-px h-8 w-8"
>
<ArrowRightIcon size={16} className="text-muted-foreground" />
</Button>
</div>
</form>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,359 @@
'use client';

import { OrganizationSwitcher, UserButton } from '@repo/auth/client';
import { ModeToggle } from '@repo/design-system/components/mode-toggle';
import { Button } from '@repo/design-system/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@repo/design-system/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@repo/design-system/components/ui/dropdown-menu';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@repo/design-system/components/ui/sidebar';
import { cn } from '@repo/design-system/lib/utils';
import { NotificationsTrigger } from '@repo/notifications/components/trigger';
import {
AnchorIcon,
BookOpenIcon,
BotIcon,
ChevronRightIcon,
FolderIcon,
FrameIcon,
LifeBuoyIcon,
MapIcon,
MoreHorizontalIcon,
PieChartIcon,
SendIcon,
Settings2Icon,
ShareIcon,
SquareTerminalIcon,
Trash2Icon,
} from 'lucide-react';
import Link from 'next/link';
import type { ReactNode } from 'react';
import { Search } from './search';

type GlobalSidebarProperties = {
readonly children: ReactNode;
};

const data = {
user: {
name: 'shadcn',
email: 'm@example.com',
avatar: '/avatars/shadcn.jpg',
},
navMain: [
{
title: 'Playground',
url: '#',
icon: SquareTerminalIcon,
isActive: true,
items: [
{
title: 'History',
url: '#',
},
{
title: 'Starred',
url: '#',
},
{
title: 'Settings',
url: '#',
},
],
},
{
title: 'Models',
url: '#',
icon: BotIcon,
items: [
{
title: 'Genesis',
url: '#',
},
{
title: 'Explorer',
url: '#',
},
{
title: 'Quantum',
url: '#',
},
],
},
{
title: 'Documentation',
url: '#',
icon: BookOpenIcon,
items: [
{
title: 'Introduction',
url: '#',
},
{
title: 'Get Started',
url: '#',
},
{
title: 'Tutorials',
url: '#',
},
{
title: 'Changelog',
url: '#',
},
],
},
{
title: 'Settings',
url: '#',
icon: Settings2Icon,
items: [
{
title: 'General',
url: '#',
},
{
title: 'Team',
url: '#',
},
{
title: 'Billing',
url: '#',
},
{
title: 'Limits',
url: '#',
},
],
},
],
navSecondary: [
{
title: 'Webhooks',
url: '/webhooks',
icon: AnchorIcon,
},
{
title: 'Support',
url: '#',
icon: LifeBuoyIcon,
},
{
title: 'Feedback',
url: '#',
icon: SendIcon,
},
],
projects: [
{
name: 'Design Engineering',
url: '#',
icon: FrameIcon,
},
{
name: 'Sales & Marketing',
url: '#',
icon: PieChartIcon,
},
{
name: 'Travel',
url: '#',
icon: MapIcon,
},
],
};

export const GlobalSidebar = ({ children }: GlobalSidebarProperties) => {
const sidebar = useSidebar();

return (
<>
<Sidebar variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div
className={cn(
'h-[36px] overflow-hidden transition-all [&>div]:w-full',
sidebar.open ? '' : '-mx-1'
)}
>
<OrganizationSwitcher
hidePersonal
afterSelectOrganizationUrl="/"
/>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<Search />
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{data.navMain.map((item) => (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={item.title}>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRightIcon />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{data.projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<Link href={item.url}>
<item.icon />
<span>{item.name}</span>
</Link>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
side="bottom"
align="end"
>
<DropdownMenuItem>
<FolderIcon className="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<ShareIcon className="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2Icon className="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton>
<MoreHorizontalIcon />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
<SidebarGroup className="mt-auto">
<SidebarGroupContent>
<SidebarMenu>
{data.navSecondary.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<UserButton
showName
appearance={{
elements: {
rootBox: 'flex overflow-hidden w-full',
userButtonBox: 'flex-row-reverse',
userButtonOuterIdentifier: 'truncate pl-0',
},
}}
/>
<div className="flex shrink-0 items-center gap-px">
<ModeToggle />
<Button
variant="ghost"
size="icon"
className="shrink-0"
asChild
>
<div className="h-4 w-4">
<NotificationsTrigger />
</div>
</Button>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<SidebarInset>{children}</SidebarInset>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { env } from '@/env';
import { currentUser } from '@repo/client/client/auth/server';
import { SidebarProvider } from '@repo/design-system/components/ui/sidebar';
import { showBetaFeature } from '@repo/feature-flags';
import { NotificationsProvider } from '@repo/notifications/components/provider';
import { secure } from '@repo/security';
import type { ReactNode } from 'react';
import { PostHogIdentifier } from './components/posthog-identifier';
import { GlobalSidebar } from './components/sidebar';

type AppLayoutProperties = {
readonly children: ReactNode;
};

const AppLayout = async ({ children }: AppLayoutProperties) => {
if (env.ARCJET_KEY) {
await secure(['CATEGORY:PREVIEW']);
}

const user = await currentUser();
const betaFeature = await showBetaFeature();

if (!user) {
return {
redirect: {
destination: '/sign-in',
permanent: false,
},
};
}

return (
<NotificationsProvider userId={user.id}>
<SidebarProvider>
<GlobalSidebar>
{betaFeature && (
<div className="m-4 rounded-full bg-blue-500 p-1.5 text-center text-sm text-white">
Beta feature now available
</div>
)}
{children}
</GlobalSidebar>
<PostHogIdentifier />
</SidebarProvider>
</NotificationsProvider>
);
};

export default AppLayout;
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { env } from '@/env';
import { auth } from '@repo/client/auth/server';
import { database } from '@repo/database';
import type { Metadata } from 'next';
import dynamic from 'next/dynamic';
import { notFound } from 'next/navigation';
import { AvatarStack } from './components/avatar-stack';
import { Cursors } from './components/cursors';
import { Header } from './components/header';

const title = 'Acme Inc';
const description = 'My application.';

const CollaborationProvider = dynamic(() =>
import('./components/collaboration-provider').then(
(mod) => mod.CollaborationProvider
)
);

export const metadata: Metadata = {
title,
description,
};

const App = async () => {
const pages = await database.page.findMany();
const { orgId } = await auth();

if (!orgId) {
notFound();
}

return (
<>
<Header pages={['Building Your Application']} page="Data Fetching">
{env.LIVEBLOCKS_SECRET && (
<CollaborationProvider orgId={orgId}>
<AvatarStack />
<Cursors />
</CollaborationProvider>
)}
</Header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
{pages.map((page) => (
<div key={page.id} className="aspect-video rounded-xl bg-muted/50">
{page.name}
</div>
))}
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
</div>
</>
);
};

export default App;
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { auth } from '@repo/client/auth/server';
import { database } from '@repo/database';
import { notFound, redirect } from 'next/navigation';
import { Header } from '../components/header';

type SearchPageProperties = {
searchParams: Promise<{
q: string;
}>;
};

export const generateMetadata = async ({
searchParams,
}: SearchPageProperties) => {
const { q } = await searchParams;

return {
title: `${q} - Search results`,
description: `Search results for ${q}`,
};
};

const SearchPage = async ({ searchParams }: SearchPageProperties) => {
const { q } = await searchParams;
const pages = await database.page.findMany({
where: {
name: {
contains: q,
},
},
});
const { orgId } = await auth();

if (!orgId) {
notFound();
}

if (!q) {
redirect('/');
}

return (
<>
<Header pages={['Building Your Application']} page="Search" />
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
{pages.map((page) => (
<div key={page.id} className="aspect-video rounded-xl bg-muted/50">
{page.name}
</div>
))}
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
</div>
</>
);
};

export default SearchPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { webhooks } from '@repo/webhooks';
import { notFound } from 'next/navigation';

export const metadata = {
title: 'Webhooks',
description: 'Send webhooks to your users.',
};

const WebhooksPage = async () => {
const response = await webhooks.getAppPortal();

if (!response?.url) {
notFound();
}

return (
<div className="h-full w-full overflow-hidden">
<iframe
title="Webhooks"
src={response.url}
className="h-full w-full border-none"
allow="clipboard-write"
loading="lazy"
/>
</div>
);
};

export default WebhooksPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { env } from '@/env';
import { ModeToggle } from '@repo/design-system/components/mode-toggle';
import { CommandIcon } from 'lucide-react';
import Link from 'next/link';
import type { ReactNode } from 'react';

type AuthLayoutProps = {
readonly children: ReactNode;
};

const AuthLayout = ({ children }: AuthLayoutProps) => (
<div className="container relative grid h-dvh flex-col items-center justify-center lg:max-w-none lg:grid-cols-2 lg:px-0">
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
<div className="absolute inset-0 bg-zinc-900" />
<div className="relative z-20 flex items-center font-medium text-lg">
<CommandIcon className="mr-2 h-6 w-6" />
Acme Inc
</div>
<div className="absolute top-4 right-4">
<ModeToggle />
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
&ldquo;This library has saved me countless hours of work and helped
me deliver stunning designs to my clients faster than ever
before.&rdquo;
</p>
<footer className="text-sm">Sofia Davis</footer>
</blockquote>
</div>
</div>
<div className="lg:p-8">
<div className="mx-auto flex w-full max-w-[400px] flex-col justify-center space-y-6">
{children}
<p className="px-8 text-center text-muted-foreground text-sm">
By clicking continue, you agree to our{' '}
<Link
href={new URL('/legal/terms', env.NEXT_PUBLIC_WEB_URL).toString()}
className="underline underline-offset-4 hover:text-primary"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
href={new URL('/legal/privacy', env.NEXT_PUBLIC_WEB_URL).toString()}
className="underline underline-offset-4 hover:text-primary"
>
Privacy Policy
</Link>
.
</p>
</div>
</div>
</div>
);

export default AuthLayout;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createMetadata } from '@repo/seo/metadata';
import type { Metadata } from 'next';
import dynamic from 'next/dynamic';

const title = 'Welcome back';
const description = 'Enter your details to sign in.';
const SignIn = dynamic(() =>
import('@repo/auth/components/sign-in').then((mod) => mod.SignIn)
);

export const metadata: Metadata = createMetadata({ title, description });

const SignInPage = () => (
<>
<div className="flex flex-col space-y-2 text-center">
<h1 className="font-semibold text-2xl tracking-tight">{title}</h1>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<SignIn />
</>
);

export default SignInPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createMetadata } from '@repo/seo/metadata';
import type { Metadata } from 'next';
import dynamic from 'next/dynamic';

const title = 'Create an account';
const description = 'Enter your details to get started.';
const SignUp = dynamic(() =>
import('@repo/auth/components/sign-up').then((mod) => mod.SignUp)
);

export const metadata: Metadata = createMetadata({ title, description });

const SignUpPage = () => (
<>
<div className="flex flex-col space-y-2 text-center">
<h1 className="font-semibold text-2xl tracking-tight">{title}</h1>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<SignUp />
</>
);

export default SignUpPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { getFlags } from '@repo/feature-flags/access';

export const GET = getFlags;
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use server';

import {
type OrganizationMembership,
auth,
clerkClient,
} from '@repo/client/auth/server';

const getName = (user: OrganizationMembership): string | undefined => {
let name = user.publicUserData?.firstName;

if (name && user.publicUserData?.lastName) {
name = `${name} ${user.publicUserData.lastName}`;
} else if (!name) {
name = user.publicUserData?.identifier;
}

return name;
};

const colors = [
'var(--color-red-500)',
'var(--color-orange-500)',
'var(--color-amber-500)',
'var(--color-yellow-500)',
'var(--color-lime-500)',
'var(--color-green-500)',
'var(--color-emerald-500)',
'var(--color-teal-500)',
'var(--color-cyan-500)',
'var(--color-sky-500)',
'var(--color-blue-500)',
'var(--color-indigo-500)',
'var(--color-violet-500)',
'var(--color-purple-500)',
'var(--color-fuchsia-500)',
'var(--color-pink-500)',
'var(--color-rose-500)',
];

export const getUsers = async (
userIds: string[]
): Promise<
| {
data: Liveblocks['UserMeta']['info'][];
}
| {
error: unknown;
}
> => {
try {
const { orgId } = await auth();

if (!orgId) {
throw new Error('Not logged in');
}

const clerk = await clerkClient();

const members = await clerk.organizations.getOrganizationMembershipList({
organizationId: orgId,
limit: 100,
});

const data: Liveblocks['UserMeta']['info'][] = members.data
.filter(
(user) =>
user.publicUserData?.userId &&
userIds.includes(user.publicUserData.userId)
)
.map((user) => ({
name: getName(user) ?? 'Unknown user',
picture: user.publicUserData?.imageUrl ?? '',
color: colors[Math.floor(Math.random() * colors.length)],
}));

return { data };
} catch (error) {
return { error };
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use server';

import {
type OrganizationMembership,
auth,
clerkClient,
} from '@repo/client/auth/server';
import Fuse from 'fuse.js';

const getName = (user: OrganizationMembership): string | undefined => {
let name = user.publicUserData?.firstName;

if (name && user.publicUserData?.lastName) {
name = `${name} ${user.publicUserData.lastName}`;
} else if (!name) {
name = user.publicUserData?.identifier;
}

return name;
};

export const searchUsers = async (
query: string
): Promise<
| {
data: string[];
}
| {
error: unknown;
}
> => {
try {
const { orgId } = await auth();

if (!orgId) {
throw new Error('Not logged in');
}

const clerk = await clerkClient();

const members = await clerk.organizations.getOrganizationMembershipList({
organizationId: orgId,
limit: 100,
});

const users = members.data.map((user) => ({
id: user.id,
name: getName(user) ?? user.publicUserData?.identifier,
imageUrl: user.publicUserData?.imageUrl,
}));

const fuse = new Fuse(users, {
keys: ['name'],
minMatchCharLength: 1,
threshold: 0.3,
});

const results = fuse.search(query);
const data = results.map((result) => result.item.id);

return { data };
} catch (error) {
return { error };
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { auth, currentUser } from '@repo/client/auth/server';
import { authenticate } from '@repo/collaboration/auth';

const COLORS = [
'var(--color-red-500)',
'var(--color-orange-500)',
'var(--color-amber-500)',
'var(--color-yellow-500)',
'var(--color-lime-500)',
'var(--color-green-500)',
'var(--color-emerald-500)',
'var(--color-teal-500)',
'var(--color-cyan-500)',
'var(--color-sky-500)',
'var(--color-blue-500)',
'var(--color-indigo-500)',
'var(--color-violet-500)',
'var(--color-purple-500)',
'var(--color-fuchsia-500)',
'var(--color-pink-500)',
'var(--color-rose-500)',
];

export const POST = async () => {
const user = await currentUser();
const { orgId } = await auth();

if (!user || !orgId) {
return new Response('Unauthorized', { status: 401 });
}

return authenticate({
userId: user.id,
orgId,
userInfo: {
name:
user.fullName ?? user.emailAddresses.at(0)?.emailAddress ?? undefined,
avatar: user.imageUrl ?? undefined,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
},
});
};
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import { Button } from '@repo/design-system/components/ui/button';
import { fonts } from '@repo/design-system/lib/fonts';
import { captureException } from '@sentry/nextjs';
import type NextError from 'next/error';
import { useEffect } from 'react';

type GlobalErrorProperties = {
readonly error: NextError & { digest?: string };
readonly reset: () => void;
};

const GlobalError = ({ error, reset }: GlobalErrorProperties) => {
useEffect(() => {
captureException(error);
}, [error]);

return (
<html lang="en" className={fonts}>
<body>
<h1>Oops, something went wrong</h1>
<Button onClick={() => reset()}>Try again</Button>
</body>
</html>
);
};

export default GlobalError;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import './styles.css';
import { DesignSystemProvider } from '@repo/design-system';
import { fonts } from '@repo/design-system/lib/fonts';
import { Toolbar } from '@repo/feature-flags/components/toolbar';
import type { ReactNode } from 'react';

type RootLayoutProperties = {
readonly children: ReactNode;
};

const RootLayout = ({ children }: RootLayoutProperties) => (
<html lang="en" className={fonts} suppressHydrationWarning>
<body>
<DesignSystemProvider>{children}</DesignSystemProvider>
<Toolbar />
</body>
</html>
);

export default RootLayout;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/app/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@repo/design-system/styles/globals.css";
29 changes: 29 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { keys as analytics } from '@repo/analytics/keys';
import { keys as collaboration } from '@repo/collaboration/keys';
import { keys as database } from '@repo/database/keys';
import { keys as email } from '@repo/email/keys';
import { keys as flags } from '@repo/feature-flags/keys';
import { keys as core } from '@repo/next-config/keys';
import { keys as notifications } from '@repo/notifications/keys';
import { keys as observability } from '@repo/observability/keys';
import { keys as security } from '@repo/security/keys';
import { keys as webhooks } from '@repo/webhooks/keys';
import { createEnv } from '@t3-oss/env-nextjs';

export const env = createEnv({
extends: [
analytics(),
collaboration(),
core(),
database(),
email(),
flags(),
notifications(),
observability(),
security(),
webhooks(),
],
server: {},
client: {},
runtimeEnv: {},
});
3 changes: 3 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { initializeSentry } from '@repo/observability/instrumentation';

export const register = initializeSentry();
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@repo/collaboration/config';
25 changes: 25 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { authMiddleware } from '@repo/client/auth/middleware';
import {
noseconeMiddleware,
noseconeOptions,
noseconeOptionsWithToolbar,
} from '@repo/security/middleware';
import type { NextMiddleware } from 'next/server';
import { env } from './env';

const securityHeaders = env.FLAGS_SECRET
? noseconeMiddleware(noseconeOptionsWithToolbar)
: noseconeMiddleware(noseconeOptions);

export default authMiddleware(() =>
securityHeaders()
) as unknown as NextMiddleware;

export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
17 changes: 17 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { env } from '@/env';
import { withToolbar } from '@repo/feature-flags/lib/toolbar';
import { config, withAnalyzer } from '@repo/next-config';
import { withLogging, withSentry } from '@repo/observability/next-config';
import type { NextConfig } from 'next';

let nextConfig: NextConfig = withToolbar(withLogging(config));

if (env.VERCEL) {
nextConfig = withSentry(nextConfig);
}

if (env.ANALYZE === 'true') {
nextConfig = withAnalyzer(nextConfig);
}

export default nextConfig;
52 changes: 52 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "app",
"private": true,
"scripts": {
"dev": "next dev -p 3000 --turbopack",
"build": "next build",
"start": "next start",
"analyze": "ANALYZE=true pnpm build",
"test": "NODE_ENV=test vitest run",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@prisma/client": "6.4.1",
"@repo/analytics": "workspace:*",
"@repo/client": "workspace:*",
"@repo/collaboration": "workspace:*",
"@repo/database": "workspace:*",
"@repo/design-system": "workspace:*",
"@repo/feature-flags": "workspace:*",
"@repo/next-config": "workspace:*",
"@repo/notifications": "workspace:*",
"@repo/observability": "workspace:*",
"@repo/security": "workspace:*",
"@repo/seo": "workspace:*",
"@repo/webhooks": "workspace:*",
"@sentry/nextjs": "^9.4.0",
"@t3-oss/env-nextjs": "^0.12.0",
"fuse.js": "^7.1.0",
"import-in-the-middle": "^1.13.1",
"lucide-react": "^0.477.0",
"next": "15.1.7",
"next-themes": "^0.4.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"require-in-the-middle": "^7.5.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@repo/testing": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "22.13.9",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"jsdom": "^26.0.0",
"tailwindcss": "^4.0.12",
"typescript": "^5.8.2",
"vitest": "^3.0.7"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@repo/design-system/postcss.config.mjs';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { initializeSentry } from '@repo/observability/client';

initializeSentry();
17 changes: 17 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@repo/*": ["../../packages/*"]
}
},
"include": [
"next-env.d.ts",
"next.config.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@repo/testing';
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: 'Create Plant'
openapi: 'POST /plants'
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: 'Delete Plant'
openapi: 'DELETE /plants/{id}'
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: 'Get Plants'
openapi: 'GET /plants'
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: 'Introduction'
description: 'Example section for showcasing API endpoints'
---

<Note>
If you're not looking to build API reference documentation, you can delete
this section by removing the api-reference folder.
</Note>

## Welcome

There are two ways to build API documentation: [OpenAPI](https://mintlify.com/docs/api-playground/openapi/setup) and [MDX components](https://mintlify.com/docs/api-playground/mdx/configuration). For the starter kit, we are using the following OpenAPI specification.

<Card
title="Plant Store Endpoints"
icon="leaf"
href="https://github.com/mintlify/starter/blob/main/api-reference/openapi.json"
>
View the OpenAPI specification file
</Card>

## Authentication

All API endpoints are authenticated using Bearer tokens and picked up from the specification file.

```json
"security": [
{
"bearerAuth": []
}
]
```
195 changes: 195 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/api-reference/openapi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
{
"openapi": "3.0.1",
"info": {
"title": "OpenAPI Plant Store",
"description": "A sample API that uses a plant store as an example to demonstrate features in the OpenAPI specification",
"license": {
"name": "MIT"
},
"version": "1.0.0"
},
"servers": [
{
"url": "http://sandbox.mintlify.com"
}
],
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/plants": {
"get": {
"description": "Returns all plants from the system that the user has access to",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "The maximum number of results to return",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Plant response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Plant"
}
}
}
}
},
"400": {
"description": "Unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"post": {
"description": "Creates a new plant in the store",
"requestBody": {
"description": "Plant to add to the store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NewPlant"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "plant response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Plant"
}
}
}
},
"400": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/plants/{id}": {
"delete": {
"description": "Deletes a single plant based on the ID supplied",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of plant to delete",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"204": {
"description": "Plant deleted",
"content": {}
},
"400": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Plant": {
"required": [
"name"
],
"type": "object",
"properties": {
"name": {
"description": "The name of the plant",
"type": "string"
},
"tag": {
"description": "Tag to specify the type",
"type": "string"
}
}
},
"NewPlant": {
"allOf": [
{
"$ref": "#/components/schemas/Plant"
},
{
"required": [
"id"
],
"type": "object",
"properties": {
"id": {
"description": "Identification number of the plant",
"type": "integer",
"format": "int64"
}
}
}
]
},
"Error": {
"required": [
"error",
"message"
],
"type": "object",
"properties": {
"error": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
}
}
}
},
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer"
}
}
}
}
98 changes: 98 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/development.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: 'Development'
description: 'Learn how to preview changes locally'
---

<Info>
**Prerequisite** You should have installed Node.js (version 18.10.0 or
higher).
</Info>

Step 1. Install Mintlify on your OS:

<CodeGroup>

```bash npm
npm i -g mintlify
```

```bash yarn
yarn global add mintlify
```

</CodeGroup>

Step 2. Go to the docs are located (where you can find `mint.json`) and run the following command:

```bash
mintlify dev
```

The documentation website is now available at `http://localhost:3000`.

### Custom Ports

Mintlify uses port 3000 by default. You can use the `--port` flag to customize the port Mintlify runs on. For example, use this command to run in port 3333:

```bash
mintlify dev --port 3333
```

You will see an error like this if you try to run Mintlify in a port that's already taken:

```md
Error: listen EADDRINUSE: address already in use :::3000
```

## Mintlify Versions

Each CLI is linked to a specific version of Mintlify. Please update the CLI if your local website looks different than production.

<CodeGroup>

```bash npm
npm i -g mintlify@latest
```

```bash yarn
yarn global upgrade mintlify
```

</CodeGroup>

## Deployment

<Tip>
Unlimited editors available under the [Startup
Plan](https://mintlify.com/pricing)
</Tip>

You should see the following if the deploy successfully went through:

<Frame>
<img src="/images/checks-passed.png" style={{ borderRadius: '0.5rem' }} />
</Frame>

## Troubleshooting

Here's how to solve some common problems when working with the CLI.

<AccordionGroup>
<Accordion title="Mintlify is not loading">
Update to Node v18. Run `mintlify install` and try again.
</Accordion>
<Accordion title="No such file or directory on Windows">
Go to the `C:/Users/Username/.mintlify/` directory and remove the `mint`
folder. Then Open the Git Bash in this location and run `git clone
https://github.com/mintlify/mint.git`.

Repeat step 3.

</Accordion>
<Accordion title="Getting an unknown error">
Try navigating to the root of your device and delete the ~/.mintlify folder.
Then run `mintlify dev` again.
</Accordion>
</AccordionGroup>

Curious about what changed in a CLI version? [Check out the CLI changelog.](/changelog/command-line)
37 changes: 37 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/essentials/code.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
title: 'Code Blocks'
description: 'Display inline code and code blocks'
icon: 'code'
---

## Basic

### Inline Code

To denote a `word` or `phrase` as code, enclose it in backticks (`).

```
To denote a `word` or `phrase` as code, enclose it in backticks (`).
```

### Code Block

Use [fenced code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks) by enclosing code in three backticks and follow the leading ticks with the programming language of your snippet to get syntax highlighting. Optionally, you can also write the name of your code after the programming language.

```java HelloWorld.java
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
```

````md
```java HelloWorld.java
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
```
````
59 changes: 59 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/essentials/images.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: 'Images and Embeds'
description: 'Add image, video, and other HTML elements'
icon: 'image'
---

<img
style={{ borderRadius: '0.5rem' }}
src="https://mintlify-assets.b-cdn.net/bigbend.jpg"
/>

## Image

### Using Markdown

The [markdown syntax](https://www.markdownguide.org/basic-syntax/#images) lets you add images using the following code

```md
![title](/path/image.jpg)
```

Note that the image file size must be less than 5MB. Otherwise, we recommend hosting on a service like [Cloudinary](https://cloudinary.com/) or [S3](https://aws.amazon.com/s3/). You can then use that URL and embed.

### Using Embeds

To get more customizability with images, you can also use [embeds](/writing-content/embed) to add images

```html
<img height="200" src="/path/image.jpg" />
```

## Embeds and HTML elements

<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/4KzFe50RQkQ"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ width: '100%', borderRadius: '0.5rem' }}
></iframe>

<br />

<Tip>

Mintlify supports [HTML tags in Markdown](https://www.markdownguide.org/basic-syntax/#html). This is helpful if you prefer HTML tags to Markdown syntax, and lets you create documentation with infinite flexibility.

</Tip>

### iFrames

Loads another HTML page within the document. Most commonly used for embedding videos.

```html
<iframe src="https://www.youtube.com/embed/4KzFe50RQkQ"> </iframe>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
title: 'Markdown Syntax'
description: 'Text, title, and styling in standard markdown'
icon: 'text-size'
---

## Titles

Best used for section headers.

```md
## Titles
```

### Subtitles

Best use to subsection headers.

```md
### Subtitles
```

<Tip>

Each **title** and **subtitle** creates an anchor and also shows up on the table of contents on the right.

</Tip>

## Text Formatting

We support most markdown formatting. Simply add `**`, `_`, or `~` around text to format it.

| Style | How to write it | Result |
| ------------- | ----------------- | --------------- |
| Bold | `**bold**` | **bold** |
| Italic | `_italic_` | _italic_ |
| Strikethrough | `~strikethrough~` | ~strikethrough~ |

You can combine these. For example, write `**_bold and italic_**` to get **_bold and italic_** text.

You need to use HTML to write superscript and subscript text. That is, add `<sup>` or `<sub>` around your text.

| Text Size | How to write it | Result |
| ----------- | ------------------------ | ---------------------- |
| Superscript | `<sup>superscript</sup>` | <sup>superscript</sup> |
| Subscript | `<sub>subscript</sub>` | <sub>subscript</sub> |

## Linking to Pages

You can add a link by wrapping text in `[]()`. You would write `[link to google](https://google.com)` to [link to google](https://google.com).

Links to pages in your docs need to be root-relative. Basically, you should include the entire folder path. For example, `[link to text](/writing-content/text)` links to the page "Text" in our components section.

Relative links like `[link to text](../text)` will open slower because we cannot optimize them as easily.

## Blockquotes

### Singleline

To create a blockquote, add a `>` in front of a paragraph.

> Dorothy followed her through many of the beautiful rooms in her castle.

```md
> Dorothy followed her through many of the beautiful rooms in her castle.
```

### Multiline

> Dorothy followed her through many of the beautiful rooms in her castle.
>
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.

```md
> Dorothy followed her through many of the beautiful rooms in her castle.
>
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
```

### LaTeX

Mintlify supports [LaTeX](https://www.latex-project.org) through the Latex component.

<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>

```md
<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
title: 'Navigation'
description: 'The navigation field in mint.json defines the pages that go in the navigation menu'
icon: 'map'
---

The navigation menu is the list of links on every website.

You will likely update `mint.json` every time you add a new page. Pages do not show up automatically.

## Navigation syntax

Our navigation syntax is recursive which means you can make nested navigation groups. You don't need to include `.mdx` in page names.

<CodeGroup>

```json Regular Navigation
"navigation": [
{
"group": "Getting Started",
"pages": ["quickstart"]
}
]
```

```json Nested Navigation
"navigation": [
{
"group": "Getting Started",
"pages": [
"quickstart",
{
"group": "Nested Reference Pages",
"pages": ["nested-reference-page"]
}
]
}
]
```

</CodeGroup>

## Folders

Simply put your MDX files in folders and update the paths in `mint.json`.

For example, to have a page at `https://yoursite.com/your-folder/your-page` you would make a folder called `your-folder` containing an MDX file called `your-page.mdx`.

<Warning>

You cannot use `api` for the name of a folder unless you nest it inside another folder. Mintlify uses Next.js which reserves the top-level `api` folder for internal server calls. A folder name such as `api-reference` would be accepted.

</Warning>

```json Navigation With Folder
"navigation": [
{
"group": "Group Name",
"pages": ["your-folder/your-page"]
}
]
```

## Hidden Pages

MDX files not included in `mint.json` will not show up in the sidebar but are accessible through the search bar and by linking directly to them.
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
title: Reusable Snippets
description: Reusable, custom snippets to keep content in sync
icon: 'recycle'
---

import SnippetIntro from '/snippets/snippet-intro.mdx';

<SnippetIntro />

## Creating a custom snippet

**Pre-condition**: You must create your snippet file in the `snippets` directory.

<Note>
Any page in the `snippets` directory will be treated as a snippet and will not
be rendered into a standalone page. If you want to create a standalone page
from the snippet, import the snippet into another file and call it as a
component.
</Note>

### Default export

1. Add content to your snippet file that you want to re-use across multiple
locations. Optionally, you can add variables that can be filled in via props
when you import the snippet.

```mdx snippets/my-snippet.mdx
Hello world! This is my content I want to reuse across pages. My keyword of the
day is {word}.
```

<Warning>
The content that you want to reuse must be inside the `snippets` directory in
order for the import to work.
</Warning>

2. Import the snippet into your destination file.

```mdx destination-file.mdx
---
title: My title
description: My Description
---

import MySnippet from '/snippets/path/to/my-snippet.mdx';

## Header

Lorem impsum dolor sit amet.

<MySnippet word="bananas" />
```

### Reusable variables

1. Export a variable from your snippet file:

```mdx snippets/path/to/custom-variables.mdx
export const myName = 'my name';

export const myObject = { fruit: 'strawberries' };
```

2. Import the snippet from your destination file and use the variable:

```mdx destination-file.mdx
---
title: My title
description: My Description
---

import { myName, myObject } from '/snippets/path/to/custom-variables.mdx';

Hello, my name is {myName} and I like {myObject.fruit}.
```

### Reusable components

1. Inside your snippet file, create a component that takes in props by exporting
your component in the form of an arrow function.

```mdx snippets/custom-component.mdx
export const MyComponent = ({ title }) => (
<div>
<h1>{title}</h1>
<p>... snippet content ...</p>
</div>
);
```

<Warning>
MDX does not compile inside the body of an arrow function. Stick to HTML
syntax when you can or use a default export if you need to use MDX.
</Warning>

2. Import the snippet into your destination file and pass in the props

```mdx destination-file.mdx
---
title: My title
description: My Description
---

import { MyComponent } from '/snippets/custom-component.mdx';

Lorem ipsum dolor sit amet.

<MyComponent title={'Custom title'} />
```
318 changes: 318 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/essentials/settings.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
---
title: 'Global Settings'
description: 'Mintlify gives you complete control over the look and feel of your documentation using the mint.json file'
icon: 'gear'
---

Every Mintlify site needs a `mint.json` file with the core configuration settings. Learn more about the [properties](#properties) below.

## Properties

<ResponseField name="name" type="string" required>
Name of your project. Used for the global title.

Example: `mintlify`

</ResponseField>

<ResponseField name="navigation" type="Navigation[]" required>
An array of groups with all the pages within that group
<Expandable title="Navigation">
<ResponseField name="group" type="string">
The name of the group.

Example: `Settings`

</ResponseField>
<ResponseField name="pages" type="string[]">
The relative paths to the markdown files that will serve as pages.

Example: `["customization", "page"]`

</ResponseField>

</Expandable>
</ResponseField>

<ResponseField name="logo" type="string or object">
Path to logo image or object with path to "light" and "dark" mode logo images
<Expandable title="Logo">
<ResponseField name="light" type="string">
Path to the logo in light mode
</ResponseField>
<ResponseField name="dark" type="string">
Path to the logo in dark mode
</ResponseField>
<ResponseField name="href" type="string" default="/">
Where clicking on the logo links you to
</ResponseField>
</Expandable>
</ResponseField>

<ResponseField name="favicon" type="string">
Path to the favicon image
</ResponseField>

<ResponseField name="colors" type="Colors">
Hex color codes for your global theme
<Expandable title="Colors">
<ResponseField name="primary" type="string" required>
The primary color. Used for most often for highlighted content, section
headers, accents, in light mode
</ResponseField>
<ResponseField name="light" type="string">
The primary color for dark mode. Used for most often for highlighted
content, section headers, accents, in dark mode
</ResponseField>
<ResponseField name="dark" type="string">
The primary color for important buttons
</ResponseField>
<ResponseField name="background" type="object">
The color of the background in both light and dark mode
<Expandable title="Object">
<ResponseField name="light" type="string" required>
The hex color code of the background in light mode
</ResponseField>
<ResponseField name="dark" type="string" required>
The hex color code of the background in dark mode
</ResponseField>
</Expandable>
</ResponseField>
</Expandable>
</ResponseField>

<ResponseField name="topbarLinks" type="TopbarLink[]">
Array of `name`s and `url`s of links you want to include in the topbar
<Expandable title="TopbarLink">
<ResponseField name="name" type="string">
The name of the button.

Example: `Contact us`
</ResponseField>
<ResponseField name="url" type="string">
The url once you click on the button. Example: `https://mintlify.com/contact`
</ResponseField>

</Expandable>
</ResponseField>

<ResponseField name="topbarCtaButton" type="Call to Action">
<Expandable title="Topbar Call to Action">
<ResponseField name="type" type={'"link" or "github"'} default="link">
Link shows a button. GitHub shows the repo information at the url provided including the number of GitHub stars.
</ResponseField>
<ResponseField name="url" type="string">
If `link`: What the button links to.

If `github`: Link to the repository to load GitHub information from.
</ResponseField>
<ResponseField name="name" type="string">
Text inside the button. Only required if `type` is a `link`.
</ResponseField>

</Expandable>
</ResponseField>

<ResponseField name="versions" type="string[]">
Array of version names. Only use this if you want to show different versions
of docs with a dropdown in the navigation bar.
</ResponseField>

<ResponseField name="anchors" type="Anchor[]">
An array of the anchors, includes the `icon`, `color`, and `url`.
<Expandable title="Anchor">
<ResponseField name="icon" type="string">
The [Font Awesome](https://fontawesome.com/search?s=brands%2Cduotone) icon used to feature the anchor.

Example: `comments`
</ResponseField>
<ResponseField name="name" type="string">
The name of the anchor label.

Example: `Community`
</ResponseField>
<ResponseField name="url" type="string">
The start of the URL that marks what pages go in the anchor. Generally, this is the name of the folder you put your pages in.
</ResponseField>
<ResponseField name="color" type="string">
The hex color of the anchor icon background. Can also be a gradient if you pass an object with the properties `from` and `to` that are each a hex color.
</ResponseField>
<ResponseField name="version" type="string">
Used if you want to hide an anchor until the correct docs version is selected.
</ResponseField>
<ResponseField name="isDefaultHidden" type="boolean" default="false">
Pass `true` if you want to hide the anchor until you directly link someone to docs inside it.
</ResponseField>
<ResponseField name="iconType" default="duotone" type="string">
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
</ResponseField>

</Expandable>
</ResponseField>

<ResponseField name="topAnchor" type="Object">
Override the default configurations for the top-most anchor.
<Expandable title="Object">
<ResponseField name="name" default="Documentation" type="string">
The name of the top-most anchor
</ResponseField>
<ResponseField name="icon" default="book-open" type="string">
Font Awesome icon.
</ResponseField>
<ResponseField name="iconType" default="duotone" type="string">
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
</ResponseField>
</Expandable>
</ResponseField>

<ResponseField name="tabs" type="Tabs[]">
An array of navigational tabs.
<Expandable title="Tabs">
<ResponseField name="name" type="string">
The name of the tab label.
</ResponseField>
<ResponseField name="url" type="string">
The start of the URL that marks what pages go in the tab. Generally, this
is the name of the folder you put your pages in.
</ResponseField>
</Expandable>
</ResponseField>

<ResponseField name="api" type="API">
Configuration for API settings. Learn more about API pages at [API Components](/api-playground/demo).
<Expandable title="API">
<ResponseField name="baseUrl" type="string">
The base url for all API endpoints. If `baseUrl` is an array, it will enable for multiple base url
options that the user can toggle.
</ResponseField>

<ResponseField name="auth" type="Auth">
<Expandable title="Auth">
<ResponseField name="method" type='"bearer" | "basic" | "key"'>
The authentication strategy used for all API endpoints.
</ResponseField>
<ResponseField name="name" type="string">
The name of the authentication parameter used in the API playground.

If method is `basic`, the format should be `[usernameName]:[passwordName]`
</ResponseField>
<ResponseField name="inputPrefix" type="string">
The default value that's designed to be a prefix for the authentication input field.

E.g. If an `inputPrefix` of `AuthKey` would inherit the default input result of the authentication field as `AuthKey`.
</ResponseField>
</Expandable>
</ResponseField>

<ResponseField name="playground" type="Playground">
Configurations for the API playground

<Expandable title="Playground">
<ResponseField name="mode" default="show" type='"show" | "simple" | "hide"'>
Whether the playground is showing, hidden, or only displaying the endpoint with no added user interactivity `simple`

Learn more at the [playground guides](/api-playground/demo)
</ResponseField>
</Expandable>
</ResponseField>

<ResponseField name="maintainOrder" type="boolean">
Enabling this flag ensures that key ordering in OpenAPI pages matches the key ordering defined in the OpenAPI file.

<Warning>This behavior will soon be enabled by default, at which point this field will be deprecated.</Warning>
</ResponseField>

</Expandable>
</ResponseField>

<ResponseField name="openapi" type="string | string[]">
A string or an array of strings of URL(s) or relative path(s) pointing to your
OpenAPI file.

Examples:
<CodeGroup>
```json Absolute
"openapi": "https://example.com/openapi.json"
```
```json Relative
"openapi": "/openapi.json"
```
```json Multiple
"openapi": ["https://example.com/openapi1.json", "/openapi2.json", "/openapi3.json"]
```
</CodeGroup>

</ResponseField>

<ResponseField name="footerSocials" type="FooterSocials">
An object of social media accounts where the key:property pair represents the social media platform and the account url.

Example:
```json
{
"x": "https://x.com/mintlify",
"website": "https://mintlify.com"
}
```
<Expandable title="FooterSocials">
<ResponseField name="[key]" type="string">
One of the following values `website`, `facebook`, `x`, `discord`, `slack`, `github`, `linkedin`, `instagram`, `hacker-news`

Example: `x`
</ResponseField>
<ResponseField name="property" type="string">
The URL to the social platform.

Example: `https://x.com/mintlify`
</ResponseField>
</Expandable>
</ResponseField>

<ResponseField name="feedback" type="Feedback">
Configurations to enable feedback buttons

<Expandable title="Feedback">
<ResponseField name="suggestEdit" type="boolean" default="false">
Enables a button to allow users to suggest edits via pull requests
</ResponseField>
<ResponseField name="raiseIssue" type="boolean" default="false">
Enables a button to allow users to raise an issue about the documentation
</ResponseField>
</Expandable>
</ResponseField>

<ResponseField name="modeToggle" type="ModeToggle">
Customize the dark mode toggle.
<Expandable title="ModeToggle">
<ResponseField name="default" type={'"light" or "dark"'}>
Set if you always want to show light or dark mode for new users. When not
set, we default to the same mode as the user's operating system.
</ResponseField>
<ResponseField name="isHidden" type="boolean" default="false">
Set to true to hide the dark/light mode toggle. You can combine `isHidden` with `default` to force your docs to only use light or dark mode. For example:

<CodeGroup>
```json Only Dark Mode
"modeToggle": {
"default": "dark",
"isHidden": true
}
```

```json Only Light Mode
"modeToggle": {
"default": "light",
"isHidden": true
}
```
</CodeGroup>

</ResponseField>

</Expandable>
</ResponseField>

<ResponseField name="backgroundImage" type="string">
A background image to be displayed behind every page. See example with
[Infisical](https://infisical.com/docs) and [FRPC](https://frpc.io).
</ResponseField>
49 changes: 49 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
161 changes: 161 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/images/hero-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
155 changes: 155 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/images/hero-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 71 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/introduction.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
title: Introduction
description: 'Welcome to the home of your new documentation'
---

<img
className="block dark:hidden"
src="/images/hero-light.svg"
alt="Hero Light"
/>
<img
className="hidden dark:block"
src="/images/hero-dark.svg"
alt="Hero Dark"
/>

## Setting up

The first step to world-class documentation is setting up your editing environments.

<CardGroup cols={2}>
<Card
title="Edit Your Docs"
icon="pen-to-square"
href="https://mintlify.com/docs/quickstart"
>
Get your docs set up locally for easy development
</Card>
<Card
title="Preview Changes"
icon="image"
href="https://mintlify.com/docs/development"
>
Preview your changes before you push to make sure they're perfect
</Card>
</CardGroup>

## Make it yours

Update your docs to your brand and add valuable content for the best user conversion.

<CardGroup cols={2}>
<Card
title="Customize Style"
icon="palette"
href="https://mintlify.com/docs/settings/global"
>
Customize your docs to your company's colors and brands
</Card>
<Card
title="Reference APIs"
icon="code"
href="https://mintlify.com/docs/api-playground/openapi"
>
Automatically generate endpoints from an OpenAPI spec
</Card>
<Card
title="Add Components"
icon="screwdriver-wrench"
href="https://mintlify.com/docs/components/accordion"
>
Build interactive features and designs to guide your users
</Card>
<Card
title="Get Inspiration"
icon="stars"
href="https://mintlify.com/customers"
>
Check out our showcase of our favorite documentation
</Card>
</CardGroup>
55 changes: 55 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/logo/dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 51 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/logo/light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
85 changes: 85 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/mint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{
"$schema": "https://mintlify.com/schema.json",
"name": "Starter Kit",
"logo": {
"dark": "/logo/dark.svg",
"light": "/logo/light.svg"
},
"favicon": "/favicon.svg",
"colors": {
"primary": "#0D9373",
"light": "#07C983",
"dark": "#0D9373",
"anchors": {
"from": "#0D9373",
"to": "#07C983"
}
},
"topbarLinks": [
{
"name": "Support",
"url": "mailto:support@mintlify.com"
}
],
"topbarCtaButton": {
"name": "Dashboard",
"url": "https://dashboard.mintlify.com"
},
"tabs": [
{
"name": "API Reference",
"url": "api-reference"
}
],
"anchors": [
{
"name": "Documentation",
"icon": "book-open-cover",
"url": "https://mintlify.com/docs"
},
{
"name": "Community",
"icon": "slack",
"url": "https://mintlify.com/community"
},
{
"name": "Blog",
"icon": "newspaper",
"url": "https://mintlify.com/blog"
}
],
"navigation": [
{
"group": "Get Started",
"pages": ["introduction", "quickstart", "development"]
},
{
"group": "Essentials",
"pages": [
"essentials/markdown",
"essentials/code",
"essentials/images",
"essentials/settings",
"essentials/navigation",
"essentials/reusable-snippets"
]
},
{
"group": "API Documentation",
"pages": ["api-reference/introduction"]
},
{
"group": "Endpoint Examples",
"pages": [
"api-reference/endpoint/get",
"api-reference/endpoint/create",
"api-reference/endpoint/delete"
]
}
],
"footerSocials": {
"x": "https://x.com/mintlify",
"github": "https://github.com/mintlify",
"linkedin": "https://www.linkedin.com/company/mintlify"
}
}
11 changes: 11 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "docs",
"private": true,
"scripts": {
"dev": "mintlify dev --port 3004",
"lint": "mintlify broken-links"
},
"devDependencies": {
"typescript": "^5.8.2"
}
}
86 changes: 86 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/docs/quickstart.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
title: 'Quickstart'
description: 'Start building awesome documentation in under 5 minutes'
---

## Setup your development

Learn how to update your docs locally and and deploy them to the public.

### Edit and preview

<AccordionGroup>
<Accordion icon="github" title="Clone your docs locally">
During the onboarding process, we created a repository on your Github with
your docs content. You can find this repository on our
[dashboard](https://dashboard.mintlify.com). To clone the repository
locally, follow these
[instructions](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository)
in your terminal.
</Accordion>
<Accordion icon="rectangle-terminal" title="Preview changes">
Previewing helps you make sure your changes look as intended. We built a
command line interface to render these changes locally. 1. Install the
[Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the
documentation changes locally with this command: ``` npm i -g mintlify ```
2. Run the following command at the root of your documentation (where
`mint.json` is): ``` mintlify dev ```
</Accordion>
</AccordionGroup>

### Deploy your changes

<AccordionGroup>

<Accordion icon="message-bot" title="Install our Github app">
Our Github app automatically deploys your changes to your docs site, so you
don't need to manage deployments yourself. You can find the link to install on
your [dashboard](https://dashboard.mintlify.com). Once the bot has been
successfully installed, there should be a check mark next to the commit hash
of the repo.
</Accordion>
<Accordion icon="rocket" title="Push your changes">
[Commit and push your changes to
Git](https://docs.github.com/en/get-started/using-git/pushing-commits-to-a-remote-repository#about-git-push)
for your changes to update in your docs site. If you push and don't see that
the Github app successfully deployed your changes, you can also manually
update your docs through our [dashboard](https://dashboard.mintlify.com).
</Accordion>

</AccordionGroup>

## Update your docs

Add content directly in your files with MDX syntax and React components. You can use any of our components, or even build your own.

<CardGroup>

<Card title="Style Your Docs" icon="paintbrush" href="/settings/global">
Add flair to your docs with personalized branding.
</Card>

<Card
title="Add API Endpoints"
icon="square-code"
href="/api-playground/configuration"
>
Implement your OpenAPI spec and enable API user interaction.
</Card>

<Card
title="Integrate Analytics"
icon="chart-mixed"
href="/analytics/supported-integrations"
>
Draw insights from user interactions with your documentation.
</Card>

<Card
title="Host on a Custom Domain"
icon="browser"
href="/settings/custom-domain/subdomain"
>
Keep your docs on your own website's subdomain.
</Card>

</CardGroup>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
One of the core principles of software development is DRY (Don't Repeat
Yourself). This is a principle that apply to documentation as
well. If you find yourself repeating the same content in multiple places, you
should consider creating a custom snippet to keep your content in sync.
1 change: 1 addition & 0 deletions ts/saas-nextforge-encore/frontend/apps/email/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.react-email
11 changes: 11 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/email/emails/contact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ContactTemplate } from '@repo/email/templates/contact';

const ExampleContactEmail = () => (
<ContactTemplate
name="Jane Smith"
email="jane.smith@example.com"
message="I'm interested in your services."
/>
);

export default ExampleContactEmail;
25 changes: 25 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/email/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "email",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "email build",
"dev": "email dev --port 3003",
"export": "email export",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@react-email/components": "0.0.33",
"@repo/email": "workspace:*",
"react": "19.0.0",
"react-email": "3.0.7"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "22.13.9",
"@types/react": "19.0.10",
"next": "15.1.7",
"typescript": "^5.8.2"
}
}
5 changes: 5 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/email/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
45 changes: 45 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/storybook/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# env files (can opt-in for commiting if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

*storybook.log

# storybook
storybook-static/
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { dirname, join } from 'node:path';
import type { StorybookConfig } from '@storybook/nextjs';

/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
const getAbsolutePath = (value: string) =>
dirname(require.resolve(join(value, 'package.json')));

const config: StorybookConfig = {
stories: [
'../stories/**/*.mdx',
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
getAbsolutePath('@storybook/addon-onboarding'),
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@chromatic-com/storybook'),
getAbsolutePath('@storybook/addon-interactions'),
getAbsolutePath('@storybook/addon-themes'),
],
framework: {
name: getAbsolutePath('@storybook/nextjs'),
options: {},
},
staticDirs: ['../public'],
};

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!-- https://github.com/vercel/geist-font/issues/72 -->

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap" rel="stylesheet">

<style>
:root {
--font-geist-sans: "Geist", sans-serif;
--font-geist-mono: "Geist Mono", monospace;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
touch-action: manipulation;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Toaster } from '@repo/design-system/components/ui/sonner';
import { TooltipProvider } from '@repo/design-system/components/ui/tooltip';
import { ThemeProvider } from '@repo/design-system/providers/theme';
import { withThemeByClassName } from '@storybook/addon-themes';
import type { Preview } from '@storybook/react';

import '@repo/design-system/styles/globals.css';

const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
chromatic: {
modes: {
light: {
theme: 'light',
className: 'light',
},
dark: {
theme: 'dark',
className: 'dark',
},
},
},
},
decorators: [
withThemeByClassName({
themes: {
light: 'light',
dark: 'dark',
},
defaultTheme: 'light',
}),
(Story) => {
return (
<div className="bg-background">
<ThemeProvider>
<TooltipProvider>
<Story />
</TooltipProvider>
<Toaster />
</ThemeProvider>
</div>
);
},
],
};

export default preview;
40 changes: 40 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/storybook/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/pages/api-reference/create-next-app).

## Getting Started

First, run the development server:

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.

[API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.

The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) instead of React pages.

This project uses [`next/font`](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn-pages-router) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
reactStrictMode: true,
};

export default nextConfig;
47 changes: 47 additions & 0 deletions ts/saas-nextforge-encore/frontend/apps/storybook/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "storybook",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "storybook dev -p 6006",
"build": "storybook build",
"chromatic": "chromatic --exit-zero-on-changes",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@repo/design-system": "workspace:*",
"@storybook/addon-actions": "^8.6.4",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.477.0",
"next": "15.1.7",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.1",
"sonner": "^2.0.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.5",
"@repo/typescript-config": "workspace:*",
"@storybook/addon-essentials": "^8.6.3",
"@storybook/addon-interactions": "^8.6.3",
"@storybook/addon-onboarding": "^8.6.3",
"@storybook/addon-themes": "^8.6.3",
"@storybook/blocks": "^8.6.3",
"@storybook/nextjs": "^8.6.3",
"@storybook/react": "^8.6.3",
"@storybook/test": "^8.6.3",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"chromatic": "^11.27.0",
"postcss": "^8",
"storybook": "^8.6.3",
"tailwindcss": "^4.0.12",
"typescript": "^5"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};

export default config;
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from '@storybook/react';

import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@repo/design-system/components/ui/accordion';

/**
* A vertically stacked set of interactive headings that each reveal a section
* of content.
*/
const meta = {
title: 'ui/Accordion',
component: Accordion,
tags: ['autodocs'],
argTypes: {
type: {
options: ['single', 'multiple'],
control: { type: 'radio' },
},
},
args: {
type: 'single',
collapsible: true,
},
render: (args) => (
<Accordion {...args}>
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that matches the other components'
aesthetic.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It's animated by default, but you can disable it if you prefer.
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Meta<typeof Accordion>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* The default behavior of the accordion allows only one item to be open.
*/
export const Default: Story = {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/react';

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@repo/design-system/components/ui/alert-dialog';

/**
* A modal dialog that interrupts the user with important content and expects
* a response.
*/
const meta = {
title: 'ui/AlertDialog',
component: AlertDialog,
tags: ['autodocs'],
argTypes: {},
render: (args) => (
<AlertDialog {...args}>
<AlertDialogTrigger>Open</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof AlertDialog>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* The default form of the alert dialog.
*/
export const Default: Story = {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AlertCircle } from 'lucide-react';

import {
Alert,
AlertDescription,
AlertTitle,
} from '@repo/design-system/components/ui/alert';

/**
* Displays a callout for user attention.
*/
const meta = {
title: 'ui/Alert',
component: Alert,
tags: ['autodocs'],
argTypes: {
variant: {
options: ['default', 'destructive'],
control: { type: 'radio' },
},
},
args: {
variant: 'default',
},
render: (args) => (
<Alert {...args}>
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>
You can add components to your app using the cli.
</AlertDescription>
</Alert>
),
} satisfies Meta<typeof Alert>;

export default meta;

type Story = StoryObj<typeof meta>;
/**
* The default form of the alert.
*/
export const Default: Story = {};

/**
* Use the `destructive` alert to indicate a destructive action.
*/
export const Destructive: Story = {
render: (args) => (
<Alert {...args}>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Your session has expired. Please log in again.
</AlertDescription>
</Alert>
),
args: {
variant: 'destructive',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react';
import Image from 'next/image';

import { AspectRatio } from '@repo/design-system/components/ui/aspect-ratio';

/**
* Displays content within a desired ratio.
*/
const meta: Meta<typeof AspectRatio> = {
title: 'ui/AspectRatio',
component: AspectRatio,
tags: ['autodocs'],
argTypes: {},
render: (args) => (
<AspectRatio {...args} className="bg-slate-50 dark:bg-slate-800">
<Image
src="https://images.unsplash.com/photo-1576075796033-848c2a5f3696?w=800&dpr=2&q=80"
alt="Photo by Alvaro Pinot"
fill
className="rounded-md object-cover"
/>
</AspectRatio>
),
decorators: [
(Story) => (
<div className="w-1/2">
<Story />
</div>
),
],
} satisfies Meta<typeof AspectRatio>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* The default form of the aspect ratio.
*/
export const Default: Story = {
args: {
ratio: 16 / 9,
},
};

/**
* Use the `1:1` aspect ratio to display a square image.
*/
export const Square: Story = {
args: {
ratio: 1,
},
};

/**
* Use the `4:3` aspect ratio to display a landscape image.
*/
export const Landscape: Story = {
args: {
ratio: 4 / 3,
},
};

/**
* Use the `2.35:1` aspect ratio to display a cinemascope image.
*/
export const Cinemascope: Story = {
args: {
ratio: 2.35 / 1,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react';

import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@repo/design-system/components/ui/avatar';

/**
* An image element with a fallback for representing the user.
*/
const meta = {
title: 'ui/Avatar',
component: Avatar,
tags: ['autodocs'],
argTypes: {},
render: (args) => (
<Avatar {...args}>
<AvatarImage src="https://github.com/shadcn.png" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
),
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof Avatar>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* The default form of the avatar.
*/
export const Default: Story = {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react';

import { Badge } from '@repo/design-system/components/ui/badge';

/**
* Displays a badge or a component that looks like a badge.
*/
const meta = {
title: 'ui/Badge',
component: Badge,
tags: ['autodocs'],
argTypes: {
children: {
control: 'text',
},
},
args: {
children: 'Badge',
},
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof Badge>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* The default form of the badge.
*/
export const Default: Story = {};

/**
* Use the `secondary` badge to call for less urgent information, blending
* into the interface while still signaling minor updates or statuses.
*/
export const Secondary: Story = {
args: {
variant: 'secondary',
},
};

/**
* Use the `destructive` badge to indicate errors, alerts, or the need for
* immediate attention.
*/
export const Destructive: Story = {
args: {
variant: 'destructive',
},
};

/**
* Use the `outline` badge for overlaying without obscuring interface details,
* emphasizing clarity and subtlety..
*/
export const Outline: Story = {
args: {
variant: 'outline',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ArrowRightSquare } from 'lucide-react';

import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@repo/design-system/components/ui/breadcrumb';

/**
* Displays the path to the current resource using a hierarchy of links.
*/
const meta = {
title: 'ui/Breadcrumb',
component: Breadcrumb,
tags: ['autodocs'],
argTypes: {},
args: {},
render: (args) => (
<Breadcrumb {...args}>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink>Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink>Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
),
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof Breadcrumb>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* Displays the path of links to the current resource.
*/
export const Default: Story = {};

/**
* Displays the path with a custom icon for the separator.
*/
export const WithCustomSeparator: Story = {
render: (args) => (
<Breadcrumb {...args}>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink>Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>
<ArrowRightSquare />
</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink>Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>
<ArrowRightSquare />
</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Loader2, Mail } from 'lucide-react';

import { Button } from '@repo/design-system/components/ui/button';

/**
* Displays a button or a component that looks like a button.
*/
const meta = {
title: 'ui/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
children: {
control: 'text',
},
},
parameters: {
layout: 'centered',
},
args: {
variant: 'default',
size: 'default',
children: 'Button',
},
} satisfies Meta<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* The default form of the button, used for primary actions and commands.
*/
export const Default: Story = {};

/**
* Use the `outline` button to reduce emphasis on secondary actions, such as
* canceling or dismissing a dialog.
*/
export const Outline: Story = {
args: {
variant: 'outline',
},
};

/**
* Use the `ghost` button is minimalistic and subtle, for less intrusive
* actions.
*/
export const Ghost: Story = {
args: {
variant: 'ghost',
},
};

/**
* Use the `secondary` button to call for less emphasized actions, styled to
* complement the primary button while being less conspicuous.
*/
export const Secondary: Story = {
args: {
variant: 'secondary',
},
};

/**
* Use the `destructive` button to indicate errors, alerts, or the need for
* immediate attention.
*/
export const Destructive: Story = {
args: {
variant: 'destructive',
},
};

/**
* Use the `link` button to reduce emphasis on tertiary actions, such as
* hyperlink or navigation, providing a text-only interactive element.
*/
export const Link: Story = {
args: {
variant: 'link',
},
};

/**
* Add the `disabled` prop to a button to prevent interactions and add a
* loading indicator, such as a spinner, to signify an in-progress action.
*/
export const Loading: Story = {
render: (args) => (
<Button {...args}>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Button
</Button>
),
args: {
...Outline.args,
disabled: true,
},
};

/**
* Add an icon element to a button to enhance visual communication and
* providing additional context for the action.
*/
export const WithIcon: Story = {
render: (args) => (
<Button {...args}>
<Mail className="mr-2 h-4 w-4" /> Login with Email Button
</Button>
),
args: {
...Secondary.args,
},
};

/**
* Use the `sm` size for a smaller button, suitable for interfaces needing
* compact elements without sacrificing usability.
*/
export const Small: Story = {
args: {
size: 'sm',
},
};

/**
* Use the `lg` size for a larger button, offering better visibility and
* easier interaction for users.
*/
export const Large: Story = {
args: {
size: 'lg',
},
};

/**
* Use the "icon" size for a button with only an icon.
*/
export const Icon: Story = {
args: {
...Secondary.args,
size: 'icon',
children: <Mail />,
},
};

/**
* Add the `disabled` prop to prevent interactions with the button.
*/
export const Disabled: Story = {
args: {
disabled: true,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import { addDays } from 'date-fns';

import { Calendar } from '@repo/design-system/components/ui/calendar';

/**
* A date field component that allows users to enter and edit date.
*/
const meta = {
title: 'ui/Calendar',
component: Calendar,
tags: ['autodocs'],
argTypes: {},
args: {
mode: 'single',
selected: new Date(),
onSelect: action('onDayClick'),
className: 'rounded-md border w-fit',
},
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof Calendar>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* The default form of the calendar.
*/
export const Default: Story = {};

/**
* Use the `multiple` mode to select multiple dates.
*/
export const Multiple: Story = {
args: {
min: 1,
selected: [new Date(), addDays(new Date(), 2), addDays(new Date(), 8)],
mode: 'multiple',
},
};

/**
* Use the `range` mode to select a range of dates.
*/
export const Range: Story = {
args: {
selected: {
from: new Date(),
to: addDays(new Date(), 7),
},
mode: 'range',
},
};

/**
* Use the `disabled` prop to disable specific dates.
*/
export const Disabled: Story = {
args: {
disabled: [
addDays(new Date(), 1),
addDays(new Date(), 2),
addDays(new Date(), 3),
addDays(new Date(), 5),
],
},
};

/**
* Use the `numberOfMonths` prop to display multiple months.
*/
export const MultipleMonths: Story = {
args: {
numberOfMonths: 2,
showOutsideDays: false,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/react';
import { BellRing } from 'lucide-react';

import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@repo/design-system/components/ui/card';

const notifications = [
{
title: 'Your call has been confirmed.',
description: '1 hour ago',
},
{
title: 'You have a new message!',
description: '1 hour ago',
},
{
title: 'Your subscription is expiring soon!',
description: '2 hours ago',
},
];

/**
* Displays a card with header, content, and footer.
*/
const meta = {
title: 'ui/Card',
component: Card,
tags: ['autodocs'],
argTypes: {},
args: {
className: 'w-96',
},
render: (args) => (
<Card {...args}>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>You have 3 unread messages.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
{notifications.map((notification, index) => (
<div key={index} className="flex items-center gap-4">
<BellRing className="size-6" />
<div>
<p>{notification.title}</p>
<p className="text-foreground/50">{notification.description}</p>
</div>
</div>
))}
</CardContent>
<CardFooter>
<button type="button" className="hover:underline">
Close
</button>
</CardFooter>
</Card>
),
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof Card>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* The default form of the card.
*/
export const Default: Story = {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/react';

import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@repo/design-system/components/ui/carousel';

/**
* A carousel with motion and swipe built using Embla.
*/
const meta: Meta<typeof Carousel> = {
title: 'ui/Carousel',
component: Carousel,
tags: ['autodocs'],
argTypes: {},
args: {
className: 'w-full max-w-xs',
},
render: (args) => (
<Carousel {...args}>
<CarouselContent>
{Array.from({ length: 5 }).map((_, index) => (
<CarouselItem key={index}>
<div className="flex aspect-square items-center justify-center rounded border bg-card p-6">
<span className="font-semibold text-4xl">{index + 1}</span>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
),
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof Carousel>;

export default meta;

type Story = StoryObj<typeof meta>;

/**
* The default form of the carousel.
*/
export const Default: Story = {};

/**
* Use the `basis` utility class to change the size of the carousel.
*/
export const Size: Story = {
render: (args) => (
<Carousel {...args} className="mx-12 w-full max-w-xs">
<CarouselContent>
{Array.from({ length: 5 }).map((_, index) => (
<CarouselItem key={index} className="basis-1/3">
<div className="flex aspect-square items-center justify-center rounded border bg-card p-6">
<span className="font-semibold text-4xl">{index + 1}</span>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
),
args: {
className: 'mx-12 w-full max-w-xs',
},
};
Loading