Added project pages and cards, most of the screens in chris samuels figma design document. Still need to implement project initialization modal and walkthrough, connect to backend and connect to wallet (maybe be beyond scope of this project)

This commit is contained in:
NasSharaf 2025-05-06 16:42:13 -04:00
parent 15bba92e2e
commit 69b8cf1395
392 changed files with 45602 additions and 0 deletions

View File

@ -0,0 +1,11 @@
---
description: Check current progress
globs:
alwaysApply: false
---
Check our progress and update the documentation
[next-agent-01.md](mdc:next-agent-01.md)
[file-migration-list.md](mdc:standards/blueprints/file-migration-list.md)
[react-component-conventions.md](mdc:standards/documentation/react-component-conventions.md)

View File

@ -0,0 +1,12 @@
---
description: Identify and execute best practice for nextjs file types
globs: app/**/*.tsx, page.tsx, layout.tsx, error.tsx, not-found.tsx, layout.tsx
alwaysApply: false
---
# Follow Next.js 15 App Router current spec
- Identify the context and file type
- Note the file's role within this specific app strucure
- consider: async, dynamic routes, metadata, error handling, loading states
- Be aware of special files and their purposes
Next.js docs for detailed specifications and best practices.
- Document components using tsdoc

View File

@ -0,0 +1,10 @@
---
description: When creating or updating UI, first use existing UI from @workspace/ui
globs: src/**/*.tsx
alwaysApply: false
---
# Always use existing UI before creating new components
Find this in
`services/ui` available with import alias `@workspace/ui/*`

30
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,30 @@
# Setting Up Contribution Guidelines with CODEOWNERS
You can create a CODEOWNERS file to define which files require specific approval before changes can be merged. This helps protect critical files in your project.
## Steps to implement CODEOWNERS:
1. Create a CODEOWNERS file in either the root directory, .github directory, or docs directory
2. Define file patterns and the users/teams who own those files
Here's how to implement it:
# CODEOWNERS file defines who needs to approve changes to specific files
# Format: file-pattern @user-or-team
# Dev container configuration
.devcontainer/* @your-username
# Core configuration files
.vscode/* @your-username
*.json @your-username
# Critical application files that need review
/src/core/* @your-username @another-team-member
/services/api/* @api-team-name
# Documentation changes can be reviewed by docs team
/docs/* @docs-team-or-username
# Default owners for everything else
* @default-team-or-username

46
.github/CONTRIBUTING.md vendored Normal file
View File

@ -0,0 +1,46 @@
# Contributing to Laconic Core
Thank you for considering contributing to our project! Here are some guidelines to help you get started.
## Development Environment
This project uses dev containers to ensure consistent development environments. To get started:
1. Install [Docker](https://www.docker.com/products/docker-desktop) and [VS Code](https://code.visualstudio.com/)
2. Install the [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension
3. Clone the repository and open it in VS Code
4. Click the green button in the bottom-left corner and select "Reopen in Container"
## Protected Files
Some files in this repository are protected and require specific approval before changes can be merged:
- `.devcontainer/*` - Dev container configuration
- `.vscode/*` - VS Code workspace settings
- `core configuration files` - Project configuration files
- `critical application files` - Core functionality
Please discuss any changes to these files with the maintainers before submitting pull requests.
## Code Style
This project uses Biome for formatting and linting. The dev container will automatically configure your editor to use the correct settings.
## Submitting Changes
1. Fork the repository
2. Create a new branch for your changes
3. Make your changes following the code style guidelines
4. Write tests for your changes
5. Submit a pull request
## Pull Request Process
1. Update the README.md with details of changes if applicable
2. The version numbers will be updated by maintainers following semantic versioning
3. Pull requests require approval from at least one maintainer
4. Once approved, maintainers will merge the PR
## Questions?
If you have questions, please open an issue or contact the maintainers.

75
.gitignore vendored Normal file
View File

@ -0,0 +1,75 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Testing
coverage
# Next.js
.next/
out/
build
dist
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Turbo
.turbo
# Vercel
.vercel
# Build outputs
dist/
build/
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Cache
.cache/
# IDE specific files
.idea/
.vscode/*
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
.pnpm-store
.cursorignore

3
.npmrc Normal file
View File

@ -0,0 +1,3 @@
@cerc-io:registry=https://git.vdb.to/api/packages/cerc-io/npm/
legacy-peer-deps=true
strict-peer-dependencies=false

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome", "ms-typescript.vscode-typescript-next"]
}

38
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,38 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.addMissingImports.ts": "explicit",
"source.organizeImports.biome": "explicit",
"source.removeUnused.ts": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"typescript.validate.enable": true,
"javascript.validate.enable": true,
"typescript.reportStyleChecksAsWarnings": true,
"typescript.surveys.enabled": false,
"prettier.enable": false,
"typescript.experimental.expandableHover": true,
"github.copilot.enable": {
"typescript": true,
"reacttypescript": true
},
"github.copilot.chat.codeGeneration.useInstructionFiles": false
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 QWRK-ORG
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

41
apps/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
dist
build
.next
out
# Testing
coverage
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.idea
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
.DS_Store
Thumbs.db

76
apps/backend/README.md Normal file
View File

@ -0,0 +1,76 @@
# backend
This backend is a [node.js](https://nodejs.org/) [express.js](https://expressjs.com/) [apollo server](https://www.apollographql.com/docs/apollo-server/) project in a [yarn workspace](https://yarnpkg.com/features/workspaces).
## Getting Started
### Install dependencies
In the root of the project, run:
```zsh
yarn
```
### Build backend
```zsh
yarn build --ignore frontend
```
### Environment variables
#### Local
Copy the `environments/local.toml.example` file to `environments/local.toml`:
```zsh
cp environments/local.toml.example environments/local.toml
```
#### Staging environment variables
In the deployment repository, update staging [staging/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/staging/configmaps/config/prod.toml)
#### Production environment variables
In the deployment repository, update production [production/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/production/configmaps/config/prod.toml)
### Run development server
```zsh
yarn start
```
## Deployment
Clone the [deployer repository](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments):
```zsh
git clone git@git.vdb.to:cerc-io/snowballtools-base-api-deployments.git
```
### Staging
```zsh
echo trigger >> .gitea/workflows/triggers/staging-deploy
git commit -a -m "Deploy v0.0.8" # replace with version number
git push
```
### Production
```zsh
echo trigger >> .gitea/workflows/triggers/production-deploy
git commit -a -m "Deploy v0.0.8" # replace with version number
git push
```
### Deployment status
Dumb for now
- [Staging](https://snowballtools-base-api.staging.apps.snowballtools.com/staging/version)
- [Production](https://snowballtools-base-api.apps.snowballtools.com/staging/version)
Update version number manually in [routes/staging.ts](/packages/backend/src/routes/staging.ts)

32
apps/backend/biome.json Normal file
View File

@ -0,0 +1,32 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
}
},
"javascript": {
"formatter": {
"enabled": true,
"quoteStyle": "single",
"trailingCommas": "none",
"semicolons": "asNeeded"
}
},
"files": {
"ignore": ["dist/**/*", "node_modules/**/*", ".turbo/**/*"]
}
}

View File

@ -0,0 +1,43 @@
[server]
host = "127.0.0.1"
port = 8000
gqlPath = "/graphql"
[server.session]
secret = ""
# Frontend webapp URL origin
appOriginUrl = "http://localhost:3000"
# Set to true if server running behind proxy
trustProxy = false
# Backend URL hostname
domain = "localhost"
[database]
dbPath = "db/snowball"
[gitHub]
webhookUrl = ""
[gitHub.oAuth]
clientId = ""
clientSecret = ""
[registryConfig]
fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000
restEndpoint = "http://localhost:1317"
gqlEndpoint = "http://localhost:9473/api"
chainId = "laconic_9000-1"
privateKey = ""
bondId = ""
authority = ""
[registryConfig.fee]
gas = ""
fees = ""
gasPrice = "1alnt"
# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions
[auction]
commitFee = "100000"
commitsDuration = "120s"
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"

View File

@ -0,0 +1,43 @@
[server]
host = "127.0.0.1"
port = 8000
gqlPath = "/graphql"
[server.session]
secret = ""
# Frontend webapp URL origin
appOriginUrl = "http://localhost:3000"
# Set to true if server running behind proxy
trustProxy = false
# Backend URL hostname
domain = "localhost"
[database]
dbPath = "db/snowball"
[gitHub]
webhookUrl = ""
[gitHub.oAuth]
clientId = ""
clientSecret = ""
[registryConfig]
fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000
restEndpoint = "http://localhost:1317"
gqlEndpoint = "http://localhost:9473/api"
chainId = "laconic_9000-1"
privateKey = ""
bondId = ""
authority = ""
[registryConfig.fee]
gas = ""
fees = ""
gasPrice = "1alnt"
# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions
[auction]
commitFee = "100000"
commitsDuration = "120s"
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"

68
apps/backend/package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "@qwrk/backend",
"license": "UNLICENSED",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"@cerc-io/registry-sdk": "^0.2.11",
"@cosmjs/stargate": "^0.33.0",
"@graphql-tools/schema": "^10.0.2",
"@graphql-tools/utils": "^10.0.12",
"@octokit/oauth-app": "^6.1.0",
"@turnkey/sdk-server": "^0.1.0",
"@types/debug": "^4.1.5",
"@types/node": "^20.11.0",
"@types/semver": "^7.5.8",
"apollo-server-core": "^3.13.0",
"apollo-server-express": "^3.13.0",
"cookie-session": "^2.1.0",
"cors": "^2.8.5",
"debug": "^4.3.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-session": "^1.18.0",
"fs-extra": "^11.2.0",
"graphql": "^16.8.1",
"luxon": "^3.5.0",
"nanoid": "3",
"nanoid-dictionary": "^5.0.0-beta.1",
"octokit": "^3.1.2",
"openpgp": "^6.0.1",
"reflect-metadata": "^0.2.1",
"semver": "^7.6.0",
"siwe": "^3.0.0",
"toml": "^3.0.0",
"ts-node": "^10.9.2",
"typeorm": "^0.3.19",
"typescript": "^5.3.3"
},
"scripts": {
"start": "DEBUG=snowball:* node --enable-source-maps ./dist/index.js",
"start:dev": "DEBUG=snowball:* ts-node ./src/index.ts",
"copy-assets": "copyfiles -u 1 src/**/*.gql dist/",
"clean": "rm -rf ./dist",
"build": "pnpm clean && tsc && pnpm copy-assets",
"format": "biome format .",
"format:check": "biome format --check .",
"lint": "biome check .",
"test:registry:init": "DEBUG=snowball:* ts-node ./test/initialize-registry.ts",
"test:registry:publish-deploy-records": "DEBUG=snowball:* ts-node ./test/publish-deploy-records.ts",
"test:registry:publish-deployment-removal-records": "DEBUG=snowball:* ts-node ./test/publish-deployment-removal-records.ts",
"test:db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts",
"test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/cookie-session": "^2.0.49",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
"@types/fs-extra": "^11.0.4",
"better-sqlite3": "^9.2.2",
"copyfiles": "^2.4.1",
"prettier": "^3.1.1",
"workspace": "^0.0.1-preview.1"
}
}

View File

@ -0,0 +1,66 @@
export interface SessionConfig {
secret: string
appOriginUrl: string
trustProxy: boolean
domain: string
}
export interface ServerConfig {
host: string
port: number
gqlPath?: string
sessionSecret: string
appOriginUrl: string
isProduction: boolean
session: SessionConfig
}
export interface DatabaseConfig {
dbPath: string
}
export interface GitHubConfig {
webhookUrl: string
oAuth: {
clientId: string
clientSecret: string
}
}
export interface RegistryConfig {
restEndpoint: string
gqlEndpoint: string
chainId: string
privateKey: string
bondId: string
fetchDeploymentRecordDelay: number
checkAuctionStatusDelay: number
authority: string
fee: {
gas: string
fees: string
gasPrice: string
}
}
export interface AuctionConfig {
commitFee: string
commitsDuration: string
revealFee: string
revealsDuration: string
denom: string
}
export interface Config {
server: ServerConfig
database: DatabaseConfig
gitHub: GitHubConfig
registryConfig: RegistryConfig
auction: AuctionConfig
turnkey: {
apiBaseUrl: string
apiPublicKey: string
apiPrivateKey: string
defaultOrganizationId: string
}
}

View File

@ -0,0 +1,7 @@
import process from 'node:process'
export const DEFAULT_CONFIG_FILE_PATH =
process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH ||
'apps/backend/environments/local.toml'
export const DEFAULT_GQL_PATH = '/graphql'

View File

@ -0,0 +1,694 @@
import assert from 'node:assert'
import path from 'node:path'
import debug from 'debug'
import { customAlphabet } from 'nanoid'
import { lowercase, numbers } from 'nanoid-dictionary'
import {
DataSource,
type DeepPartial,
type FindManyOptions,
type FindOneOptions,
type FindOptionsWhere,
IsNull,
Not
} from 'typeorm'
import type { DatabaseConfig } from './config'
import { Deployer } from './entity/Deployer'
import { Deployment, DeploymentStatus } from './entity/Deployment'
import { Domain } from './entity/Domain'
import { EnvironmentVariable } from './entity/EnvironmentVariable'
import { Organization } from './entity/Organization'
import { Project } from './entity/Project'
import { ProjectMember } from './entity/ProjectMember'
import { User } from './entity/User'
import { UserOrganization } from './entity/UserOrganization'
import type { DNSRecordAttributes } from './types'
import { getEntities, loadAndSaveData } from './utils'
const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json'
const log = debug('snowball:database')
const nanoid = customAlphabet(lowercase + numbers, 8)
// TODO: Fix order of methods
export class Database {
private dataSource: DataSource
constructor({ dbPath }: DatabaseConfig) {
this.dataSource = new DataSource({
type: 'better-sqlite3',
database: dbPath,
entities: [path.join(__dirname, '/entity/*')],
synchronize: true,
logging: false
})
}
async init(): Promise<void> {
await this.dataSource.initialize()
log('database initialized')
let organizations = await this.getOrganizations({})
// Load an organization if none exist
if (!organizations.length) {
const orgEntities = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
)
organizations = await loadAndSaveData(Organization, this.dataSource, [
orgEntities[0]
])
}
// Hotfix for updating old DB data
if (organizations[0].slug === 'snowball-tools-1') {
const [orgEntity] = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
)
await this.updateOrganization(organizations[0].id, {
slug: orgEntity.slug as string,
name: orgEntity.name as string
})
}
}
async getUser(options: FindOneOptions<User>): Promise<User | null> {
const userRepository = this.dataSource.getRepository(User)
const user = await userRepository.findOne(options)
return user
}
async addUser(data: DeepPartial<User>): Promise<User> {
const userRepository = this.dataSource.getRepository(User)
const user = await userRepository.save(data)
return user
}
async updateUser(user: User, data: DeepPartial<User>): Promise<boolean> {
const userRepository = this.dataSource.getRepository(User)
const updateResult = await userRepository.update({ id: user.id }, data)
assert(updateResult.affected)
return updateResult.affected > 0
}
async getOrganizations(
options: FindManyOptions<Organization>
): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization)
const organizations = await organizationRepository.find(options)
return organizations
}
async getOrganization(
options: FindOneOptions<Organization>
): Promise<Organization | null> {
const organizationRepository = this.dataSource.getRepository(Organization)
const organization = await organizationRepository.findOne(options)
return organization
}
async getOrganizationsByUserId(userId: string): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization)
const userOrgs = await organizationRepository.find({
where: {
userOrganizations: {
member: {
id: userId
}
}
}
})
return userOrgs
}
async addUserOrganization(
data: DeepPartial<UserOrganization>
): Promise<UserOrganization> {
const userOrganizationRepository =
this.dataSource.getRepository(UserOrganization)
const newUserOrganization = await userOrganizationRepository.save(data)
return newUserOrganization
}
async updateOrganization(
organizationId: string,
data: DeepPartial<Organization>
): Promise<boolean> {
const organizationRepository = this.dataSource.getRepository(Organization)
const updateResult = await organizationRepository.update(
{ id: organizationId },
data
)
assert(updateResult.affected)
return updateResult.affected > 0
}
async getProjects(options: FindManyOptions<Project>): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project)
const projects = await projectRepository.find(options)
return projects
}
async getProjectById(projectId: string): Promise<Project | null> {
const projectRepository = this.dataSource.getRepository(Project)
const project = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect(
'project.deployments',
'deployments',
'deployments.isCurrent = true AND deployments.isCanonical = true'
)
.leftJoinAndSelect('deployments.createdBy', 'user')
.leftJoinAndSelect('deployments.deployer', 'deployer')
.leftJoinAndSelect('project.owner', 'owner')
.leftJoinAndSelect('project.deployers', 'deployers')
.leftJoinAndSelect('project.organization', 'organization')
.where('project.id = :projectId', {
projectId
})
.getOne()
return project
}
async allProjectsWithoutDeployments(): Promise<Project[]> {
const allProjects = await this.getProjects({
where: {
auctionId: Not(IsNull())
},
relations: ['deployments'],
withDeleted: true
})
const projects = allProjects.filter((project) => {
if (project.deletedAt !== null) return false
return project.deployments.length === 0
})
return projects
}
async getProjectsInOrganization(
userId: string,
organizationSlug: string
): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project)
const projects = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect(
'project.deployments',
'deployments',
'deployments.isCurrent = true AND deployments.isCanonical = true'
)
.leftJoin('project.projectMembers', 'projectMembers')
.leftJoin('project.organization', 'organization')
.where(
'(project.ownerId = :userId OR projectMembers.userId = :userId) AND organization.slug = :organizationSlug',
{
userId,
organizationSlug
}
)
.getMany()
return projects
}
/**
* Get deployments with specified filter
*/
async getDeployments(
options: FindManyOptions<Deployment>
): Promise<Deployment[]> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployments = await deploymentRepository.find(options)
return deployments
}
async getDeploymentsByProjectId(projectId: string): Promise<Deployment[]> {
return this.getDeployments({
relations: {
project: true,
createdBy: true,
deployer: true
},
where: {
project: {
id: projectId
}
},
order: {
createdAt: 'DESC'
}
})
}
async getNonCanonicalDeploymentsByProjectId(
projectId: string
): Promise<Deployment[]> {
return this.getDeployments({
relations: {
project: true,
createdBy: true,
deployer: true
},
where: {
project: {
id: projectId
},
isCanonical: false
},
order: {
createdAt: 'DESC'
}
})
}
async getDeployment(
options: FindOneOptions<Deployment>
): Promise<Deployment | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployment = await deploymentRepository.findOne(options)
return deployment
}
async getDomains(options: FindManyOptions<Domain>): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain)
const domains = await domainRepository.find(options)
return domains
}
async addDeployment(data: DeepPartial<Deployment>): Promise<Deployment> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const id = nanoid()
const updatedData = {
...data,
id
}
const deployment = await deploymentRepository.save(updatedData)
return deployment
}
async getProjectMembersByProjectId(
projectId: string
): Promise<ProjectMember[]> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const projectMembers = await projectMemberRepository.find({
relations: {
project: true,
member: true
},
where: {
project: {
id: projectId
}
}
})
return projectMembers
}
async getEnvironmentVariablesByProjectId(
projectId: string,
filter?: FindOptionsWhere<EnvironmentVariable>
): Promise<EnvironmentVariable[]> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const environmentVariables = await environmentVariableRepository.find({
where: {
project: {
id: projectId
},
...filter
}
})
return environmentVariables
}
async removeProjectMemberById(projectMemberId: string): Promise<boolean> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const deleteResult = await projectMemberRepository.delete({
id: projectMemberId
})
if (deleteResult.affected) {
return deleteResult.affected > 0
}
return false
}
async updateProjectMemberById(
projectMemberId: string,
data: DeepPartial<ProjectMember>
): Promise<boolean> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const updateResult = await projectMemberRepository.update(
{ id: projectMemberId },
data
)
return Boolean(updateResult.affected)
}
async addProjectMember(
data: DeepPartial<ProjectMember>
): Promise<ProjectMember> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const newProjectMember = await projectMemberRepository.save(data)
return newProjectMember
}
async addEnvironmentVariables(
data: DeepPartial<EnvironmentVariable>[]
): Promise<EnvironmentVariable[]> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const savedEnvironmentVariables =
await environmentVariableRepository.save(data)
return savedEnvironmentVariables
}
async updateEnvironmentVariable(
environmentVariableId: string,
data: DeepPartial<EnvironmentVariable>
): Promise<boolean> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const updateResult = await environmentVariableRepository.update(
{ id: environmentVariableId },
data
)
return Boolean(updateResult.affected)
}
async deleteEnvironmentVariable(
environmentVariableId: string
): Promise<boolean> {
const environmentVariableRepository =
this.dataSource.getRepository(EnvironmentVariable)
const deleteResult = await environmentVariableRepository.delete({
id: environmentVariableId
})
if (deleteResult.affected) {
return deleteResult.affected > 0
}
return false
}
async getProjectMemberById(projectMemberId: string): Promise<ProjectMember> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember)
const projectMemberWithProject = await projectMemberRepository.find({
relations: {
project: {
owner: true
},
member: true
},
where: {
id: projectMemberId
}
})
if (projectMemberWithProject.length === 0) {
throw new Error('Member does not exist')
}
return projectMemberWithProject[0]
}
async getProjectsBySearchText(
userId: string,
searchText: string
): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project)
const projects = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect('project.organization', 'organization')
.leftJoin('project.projectMembers', 'projectMembers')
.where(
'(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText',
{
userId,
searchText: `%${searchText}%`
}
)
.getMany()
return projects
}
async updateDeploymentById(
deploymentId: string,
data: DeepPartial<Deployment>
): Promise<boolean> {
return this.updateDeployment({ id: deploymentId }, data)
}
async updateDeployment(
criteria: FindOptionsWhere<Deployment>,
data: DeepPartial<Deployment>
): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const updateResult = await deploymentRepository.update(criteria, data)
return Boolean(updateResult.affected)
}
async updateDeploymentsByProjectIds(
projectIds: string[],
data: DeepPartial<Deployment>
): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const updateResult = await deploymentRepository
.createQueryBuilder()
.update(Deployment)
.set(data)
.where('projectId IN (:...projectIds)', { projectIds })
.execute()
return Boolean(updateResult.affected)
}
async deleteDeploymentById(deploymentId: string): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployment = await deploymentRepository.findOneOrFail({
where: {
id: deploymentId
}
})
const deleteResult = await deploymentRepository.softRemove(deployment)
return Boolean(deleteResult)
}
async addProject(
user: User,
organizationId: string,
data: DeepPartial<Project>
): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project)
// TODO: Check if organization exists
const newProject = projectRepository.create(data)
// TODO: Set default empty array for webhooks in TypeORM
newProject.webhooks = []
// TODO: Set icon according to framework
newProject.icon = ''
newProject.owner = user
newProject.organization = Object.assign(new Organization(), {
id: organizationId
})
return projectRepository.save(newProject)
}
async saveProject(project: Project): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project)
return projectRepository.save(project)
}
async updateProjectById(
projectId: string,
data: DeepPartial<Project>
): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project)
const updateResult = await projectRepository.update({ id: projectId }, data)
return Boolean(updateResult.affected)
}
async deleteProjectById(projectId: string): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project)
const project = await projectRepository.findOneOrFail({
where: {
id: projectId
},
relations: {
projectMembers: true
}
})
const deleteResult = await projectRepository.softRemove(project)
return Boolean(deleteResult)
}
async deleteDomainById(domainId: string): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain)
const deleteResult = await domainRepository.softDelete({ id: domainId })
if (deleteResult.affected) {
return deleteResult.affected > 0
}
return false
}
async addDomain(data: DeepPartial<Domain>): Promise<Domain> {
const domainRepository = this.dataSource.getRepository(Domain)
const newDomain = await domainRepository.save(data)
return newDomain
}
async getDomain(options: FindOneOptions<Domain>): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain)
const domain = await domainRepository.findOne(options)
return domain
}
async updateDomainById(
domainId: string,
data: DeepPartial<Domain>
): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain)
const updateResult = await domainRepository.update({ id: domainId }, data)
return Boolean(updateResult.affected)
}
async getDomainsByProjectId(
projectId: string,
filter?: FindOptionsWhere<Domain>
): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain)
const domains = await domainRepository.find({
relations: {
redirectTo: true
},
where: {
project: {
id: projectId
},
...filter
}
})
return domains
}
async getOldestDomainByProjectId(projectId: string): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain)
const domain = await domainRepository.findOne({
where: {
project: {
id: projectId
}
},
order: {
createdAt: 'ASC'
}
})
return domain
}
async getLatestDNSRecordByProjectId(
projectId: string
): Promise<DNSRecordAttributes | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment)
const deployment = await deploymentRepository.findOne({
where: {
project: {
id: projectId
},
status: DeploymentStatus.Ready
},
order: {
createdAt: 'DESC'
}
})
if (deployment === null) {
throw new Error(`No deployment found for project ${projectId}`)
}
return deployment.dnsRecordData
}
async addDeployer(data: DeepPartial<Deployer>): Promise<Deployer> {
const deployerRepository = this.dataSource.getRepository(Deployer)
const newDomain = await deployerRepository.save(data)
return newDomain
}
async getDeployers(): Promise<Deployer[]> {
const deployerRepository = this.dataSource.getRepository(Deployer)
const deployers = await deployerRepository.find()
return deployers
}
async getDeployerByLRN(deployerLrn: string): Promise<Deployer | null> {
const deployerRepository = this.dataSource.getRepository(Deployer)
const deployer = await deployerRepository.findOne({
where: { deployerLrn }
})
return deployer
}
}

View File

@ -0,0 +1,32 @@
import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm'
import { Project } from './Project'
@Entity()
export class Deployer {
@PrimaryColumn('varchar')
deployerLrn!: string
@Column('varchar')
deployerId!: string
@Column('varchar')
deployerApiUrl!: string
@Column('varchar')
baseDomain!: string
@Column('varchar', { nullable: true })
publicKey!: string | null
@Column('varchar', { nullable: true })
minimumPayment!: string | null
@Column('varchar', { nullable: true })
paymentAddress!: string | null
@ManyToMany(
() => Project,
(project) => project.deployers
)
projects!: Project[]
}

View File

@ -0,0 +1,159 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn
} from 'typeorm'
import type {
AppDeploymentRecordAttributes,
AppDeploymentRemovalRecordAttributes,
DNSRecordAttributes
} from '../types'
import { Deployer } from './Deployer'
import { Project } from './Project'
import { User } from './User'
export enum Environment {
Production = 'Production',
Preview = 'Preview',
Development = 'Development'
}
export enum DeploymentStatus {
Building = 'Building',
Ready = 'Ready',
Error = 'Error',
Deleting = 'Deleting'
}
export interface ApplicationDeploymentRequest {
type: string
version: string
name: string
application: string
lrn?: string
auction?: string
config: string
meta: string
payment?: string
dns?: string
}
export interface ApplicationDeploymentRemovalRequest {
type: string
version: string
deployment: string
auction?: string
payment?: string
}
export interface ApplicationRecord {
type: string
version: string
name: string
description?: string
homepage?: string
license?: string
author?: string
repository?: string[]
app_version?: string
repository_ref: string
app_type: string
}
@Entity()
export class Deployment {
// TODO: set custom generated id
@PrimaryColumn('varchar')
id!: string
@Column()
projectId!: string
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project
@Column('varchar')
branch!: string
@Column('varchar')
commitHash!: string
@Column('varchar')
commitMessage!: string
@Column('varchar', { nullable: true })
url!: string | null
@Column('varchar')
applicationRecordId!: string
@Column('simple-json')
applicationRecordData!: ApplicationRecord
@Column('varchar', { nullable: true })
applicationDeploymentRequestId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRequestData!: ApplicationDeploymentRequest | null
@Column('varchar', { nullable: true })
applicationDeploymentRecordId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRequestId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRequestData!: ApplicationDeploymentRemovalRequest | null
@Column('varchar', { nullable: true })
applicationDeploymentRemovalRecordId!: string | null
@Column('simple-json', { nullable: true })
applicationDeploymentRemovalRecordData!: AppDeploymentRemovalRecordAttributes | null
@Column('simple-json', { nullable: true })
dnsRecordData!: DNSRecordAttributes | null
@ManyToOne(() => Deployer)
@JoinColumn({ name: 'deployerLrn' })
deployer!: Deployer
@Column({
enum: Environment
})
environment!: Environment
@Column('boolean', { default: false })
isCurrent!: boolean
@Column('boolean', { default: false })
isCanonical!: boolean
@Column({
enum: DeploymentStatus
})
status!: DeploymentStatus
@ManyToOne(() => User)
@JoinColumn({ name: 'createdBy' })
createdBy!: User
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}

View File

@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Project } from './Project'
export enum Status {
Live = 'Live',
Pending = 'Pending'
}
@Entity()
export class Domain {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('varchar')
projectId!: string
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project
@Column('varchar', { length: 255, default: 'main' })
branch!: string
@Column('varchar', { length: 255 })
name!: string
@Column('string', { nullable: true })
redirectToId!: string | null
@ManyToOne(() => Domain)
@JoinColumn({ name: 'redirectToId' })
// eslint-disable-next-line no-use-before-define
redirectTo!: Domain | null
@Column({
enum: Status,
default: Status.Pending
})
status!: Status
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}

View File

@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Project } from './Project'
enum Environment {
Production = 'Production',
Preview = 'Preview',
Development = 'Development'
}
@Entity()
export class EnvironmentVariable {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' })
project!: Project
@Column({
enum: Environment
})
environment!: Environment
@Column('varchar')
key!: string
@Column('varchar')
value!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
}

View File

@ -0,0 +1,38 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn
} from 'typeorm'
import { UserOrganization } from './UserOrganization'
@Entity()
@Unique(['slug'])
export class Organization {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('varchar', { length: 255 })
name!: string
@Column('varchar')
slug!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@OneToMany(
() => UserOrganization,
(userOrganization) => userOrganization.organization,
{
cascade: ['soft-remove']
}
)
userOrganizations!: UserOrganization[]
}

View File

@ -0,0 +1,111 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Deployer } from './Deployer'
import { Deployment } from './Deployment'
import { Organization } from './Organization'
import { ProjectMember } from './ProjectMember'
import { User } from './User'
@Entity()
export class Project {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'ownerId' })
owner!: User
@Column({ nullable: false })
ownerId!: string
@ManyToOne(() => Organization, { nullable: true })
@JoinColumn({ name: 'organizationId' })
organization!: Organization | null
@Column('varchar')
organizationId!: string
@Column('varchar')
name!: string
@Column('varchar')
repository!: string
@Column('varchar', { length: 255, default: 'main' })
prodBranch!: string
@Column('text', { default: '' })
description!: string
@Column('varchar', { nullable: true })
auctionId!: string | null
// Tx hash for sending coins from snowball to deployer
@Column('varchar', { nullable: true })
txHash!: string | null
@ManyToMany(
() => Deployer,
(deployer) => deployer.projects
)
@JoinTable()
deployers!: Deployer[]
@Column('boolean', { default: false, nullable: true })
fundsReleased!: boolean
// TODO: Compute template & framework in import repository
@Column('varchar', { nullable: true })
template!: string | null
@Column('varchar', { nullable: true })
framework!: string | null
// Address of the user who created the project i.e. requested deployments
@Column('varchar')
paymentAddress!: string
@Column({
type: 'simple-array'
})
webhooks!: string[]
@Column('varchar')
icon!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
@OneToMany(
() => Deployment,
(deployment) => deployment.project
)
deployments!: Deployment[]
@OneToMany(
() => ProjectMember,
(projectMember) => projectMember.project,
{
cascade: ['soft-remove']
}
)
projectMembers!: ProjectMember[]
}

View File

@ -0,0 +1,57 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn
} from 'typeorm'
import { Project } from './Project'
import { User } from './User'
export enum Permission {
View = 'View',
Edit = 'Edit'
}
@Entity()
@Unique(['project', 'member'])
export class ProjectMember {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(
() => User,
(user) => user.projectMembers
)
@JoinColumn({ name: 'userId' })
member!: User
@ManyToOne(
() => Project,
(project) => project.projectMembers
)
@JoinColumn({ name: 'projectId' })
project!: Project
@Column({
type: 'simple-array'
})
permissions!: Permission[]
@Column('boolean', { default: false })
isPending!: boolean
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}

View File

@ -0,0 +1,65 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
Unique
} from 'typeorm'
import { ProjectMember } from './ProjectMember'
import { UserOrganization } from './UserOrganization'
@Entity()
@Unique(['email'])
@Unique(['ethAddress'])
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string
// TODO: Set ethAddress as ID
@Column()
ethAddress!: string
@Column('varchar', { length: 255, nullable: true })
name!: string | null
@Column()
email!: string
@Column('varchar', { nullable: true })
gitHubToken!: string | null
@Column('boolean', { default: false })
isVerified!: boolean
@CreateDateColumn()
createdAt!: Date
@CreateDateColumn()
updatedAt!: Date
@Column()
subOrgId!: string
@Column()
turnkeyWalletId!: string
@OneToMany(
() => ProjectMember,
(projectMember) => projectMember.project,
{
cascade: ['soft-remove']
}
)
projectMembers!: ProjectMember[]
@OneToMany(
() => UserOrganization,
(UserOrganization) => UserOrganization.member,
{
cascade: ['soft-remove']
}
)
userOrganizations!: UserOrganization[]
}

View File

@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm'
import { Organization } from './Organization'
import { User } from './User'
export enum Role {
Owner = 'Owner',
Maintainer = 'Maintainer',
Reader = 'Reader'
}
@Entity()
export class UserOrganization {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
member!: User
@ManyToOne(() => Organization)
@JoinColumn({ name: 'organizationId' })
organization!: Organization
@Column({
enum: Role
})
role!: Role
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@DeleteDateColumn()
deletedAt!: Date | null
}

53
apps/backend/src/index.ts Normal file
View File

@ -0,0 +1,53 @@
import 'express-async-errors'
import 'reflect-metadata'
import fs from 'node:fs'
import path from 'node:path'
import debug from 'debug'
import { OAuthApp } from '@octokit/oauth-app'
import { Database } from './database'
import { Registry } from './registry'
import { createResolvers } from './resolvers'
import { createAndStartServer } from './server'
import { Service } from './service'
import { getConfig } from './utils'
const log = debug('snowball:server')
const OAUTH_CLIENT_TYPE = 'oauth-app'
export const main = async (): Promise<void> => {
const { server, database, gitHub, registryConfig } = await getConfig()
const app = new OAuthApp({
clientType: OAUTH_CLIENT_TYPE,
clientId: gitHub.oAuth.clientId,
clientSecret: gitHub.oAuth.clientSecret
})
const db = new Database(database)
await db.init()
const registry = new Registry(registryConfig)
const service = new Service(
{ gitHubConfig: gitHub, registryConfig },
db,
app,
registry
)
const typeDefs = fs
.readFileSync(path.join(__dirname, 'schema.gql'))
.toString()
const resolvers = await createResolvers(service)
await createAndStartServer(server, typeDefs, resolvers, service)
}
main()
.then(() => {
log('Starting server...')
})
.catch((err) => {
log(err)
})

View File

@ -0,0 +1,624 @@
import assert from 'node:assert'
import debug from 'debug'
import { DateTime } from 'luxon'
import type { Octokit } from 'octokit'
import * as openpgp from 'openpgp'
import { inc as semverInc } from 'semver'
import type { DeepPartial } from 'typeorm'
import {
Account,
DEFAULT_GAS_ESTIMATION_MULTIPLIER,
Registry as LaconicRegistry,
getGasPrice,
parseGasAndFees
} from '@cerc-io/registry-sdk'
import type { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate'
import type { RegistryConfig } from './config'
import type {
ApplicationDeploymentRemovalRequest,
ApplicationDeploymentRequest,
ApplicationRecord,
Deployment
} from './entity/Deployment'
import type {
AppDeploymentRecord,
AppDeploymentRemovalRecord,
AuctionParams,
DeployerRecord,
RegistryRecord
} from './types'
import {
getConfig,
getRepoDetails,
registryTransactionWithRetry,
sleep
} from './utils'
const log = debug('snowball:registry')
const APP_RECORD_TYPE = 'ApplicationRecord'
const APP_DEPLOYMENT_AUCTION_RECORD_TYPE = 'ApplicationDeploymentAuction'
const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest'
const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE =
'ApplicationDeploymentRemovalRequest'
const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord'
const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord'
const WEBAPP_DEPLOYER_RECORD_TYPE = 'WebappDeployer'
const SLEEP_DURATION = 1000
// TODO: Move registry code to registry-sdk/watcher-ts
export class Registry {
private registry: LaconicRegistry
private registryConfig: RegistryConfig
constructor(registryConfig: RegistryConfig) {
this.registryConfig = registryConfig
const gasPrice = getGasPrice(registryConfig.fee.gasPrice)
this.registry = new LaconicRegistry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId, gasPrice }
)
}
async createApplicationRecord({
octokit,
repository,
commitHash,
appType
}: {
octokit: Octokit
repository: string
commitHash: string
appType: string
}): Promise<{
applicationRecordId: string
applicationRecordData: ApplicationRecord
}> {
const { repo, repoUrl, packageJSON } = await getRepoDetails(
octokit,
repository,
commitHash
)
// Use registry-sdk to publish record
// Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
// Fetch previous records
const records = await this.registry.queryRecords(
{
type: APP_RECORD_TYPE,
name: packageJSON.name
},
true
)
// Get next version of record
const bondRecords = records.filter(
(record: any) => record.bondId === this.registryConfig.bondId
)
const [latestBondRecord] = bondRecords.sort(
(a: any, b: any) =>
new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
)
const nextVersion = semverInc(
latestBondRecord?.attributes.version ?? '0.0.0',
'patch'
)
assert(nextVersion, 'Application record version not valid')
// Create record of type ApplicationRecord and publish
const applicationRecord = {
type: APP_RECORD_TYPE,
version: nextVersion,
repository_ref: commitHash,
repository: [repoUrl],
app_type: appType,
name: repo,
...(packageJSON.description && { description: packageJSON.description }),
...(packageJSON.homepage && { homepage: packageJSON.homepage }),
...(packageJSON.license && { license: packageJSON.license }),
...(packageJSON.author && {
author:
typeof packageJSON.author === 'object'
? JSON.stringify(packageJSON.author)
: packageJSON.author
}),
...(packageJSON.version && { app_version: packageJSON.version })
}
const result = await this.publishRecord(applicationRecord)
log(`Published application record ${result.id}`)
log('Application record data:', applicationRecord)
// TODO: Discuss computation of LRN
const lrn = this.getLrn(repo)
log(`Setting name: ${lrn} for record ID: ${result.id}`)
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
await sleep(SLEEP_DURATION)
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn
},
this.registryConfig.privateKey,
fee
)
)
await sleep(SLEEP_DURATION)
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.app_version}`
},
this.registryConfig.privateKey,
fee
)
)
await sleep(SLEEP_DURATION)
await registryTransactionWithRetry(() =>
this.registry.setName(
{
cid: result.id,
lrn: `${lrn}@${applicationRecord.repository_ref}`
},
this.registryConfig.privateKey,
fee
)
)
return {
applicationRecordId: result.id,
applicationRecordData: applicationRecord
}
}
async createApplicationDeploymentAuction(
appName: string,
octokit: Octokit,
auctionParams: AuctionParams,
data: DeepPartial<Deployment>
): Promise<{
applicationDeploymentAuctionId: string
}> {
assert(data.project?.repository, 'Project repository not found')
await this.createApplicationRecord({
octokit,
repository: data.project.repository,
appType: data.project!.template!,
commitHash: data.commitHash!
})
const lrn = this.getLrn(appName)
const config = await getConfig()
const auctionConfig = config.auction
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const auctionResult = await registryTransactionWithRetry(() =>
this.registry.createProviderAuction(
{
commitFee: auctionConfig.commitFee,
commitsDuration: auctionConfig.commitsDuration,
revealFee: auctionConfig.revealFee,
revealsDuration: auctionConfig.revealsDuration,
denom: auctionConfig.denom,
maxPrice: auctionParams.maxPrice,
numProviders: auctionParams.numProviders
},
this.registryConfig.privateKey,
fee
)
)
if (!auctionResult.auction) {
throw new Error('Error creating auction')
}
// Create record of type applicationDeploymentAuction and publish
const applicationDeploymentAuction = {
application: lrn,
auction: auctionResult.auction.id,
type: APP_DEPLOYMENT_AUCTION_RECORD_TYPE
}
const result = await this.publishRecord(applicationDeploymentAuction)
log(`Application deployment auction created: ${auctionResult.auction.id}`)
log(`Application deployment auction record published: ${result.id}`)
log('Application deployment auction data:', applicationDeploymentAuction)
return {
applicationDeploymentAuctionId: auctionResult.auction.id
}
}
async createApplicationDeploymentRequest(data: {
deployment: Deployment
appName: string
repository: string
auctionId?: string | null
lrn: string
apiUrl: string
environmentVariables: { [key: string]: string }
dns: string
requesterAddress: string
publicKey: string
payment?: string | null
}): Promise<{
applicationDeploymentRequestId: string
applicationDeploymentRequestData: ApplicationDeploymentRequest
}> {
const lrn = this.getLrn(data.appName)
const records = await this.registry.resolveNames([lrn])
const applicationRecord = records[0]
if (!applicationRecord) {
throw new Error(`No record found for ${lrn}`)
}
let hash: string | undefined
if (Object.keys(data.environmentVariables).length !== 0) {
hash = await this.generateConfigHash(
data.environmentVariables,
data.requesterAddress,
data.publicKey,
data.apiUrl
)
}
// Create record of type ApplicationDeploymentRequest and publish
const applicationDeploymentRequest = {
type: APP_DEPLOYMENT_REQUEST_TYPE,
version: '1.0.0',
name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`,
application: `${lrn}@${applicationRecord.attributes.app_version}`,
dns: data.dns,
// https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b
config: JSON.stringify(hash ? { ref: hash } : {}),
meta: JSON.stringify({
note: `Added by Snowball @ ${DateTime.utc().toFormat(
"EEE LLL dd HH:mm:ss 'UTC' yyyy"
)}`,
repository: data.repository,
repository_ref: data.deployment.commitHash
}),
deployer: data.lrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment })
}
await sleep(SLEEP_DURATION)
const result = await this.publishRecord(applicationDeploymentRequest)
log(`Application deployment request record published: ${result.id}`)
log('Application deployment request data:', applicationDeploymentRequest)
return {
applicationDeploymentRequestId: result.id,
applicationDeploymentRequestData: applicationDeploymentRequest
}
}
async getAuctionWinningDeployerRecords(
auctionId: string
): Promise<DeployerRecord[]> {
const records = await this.registry.getAuctionsByIds([auctionId])
const auctionResult = records[0]
const deployerRecords = []
const { winnerAddresses } = auctionResult
for (const auctionWinner of winnerAddresses) {
const records = await this.getDeployerRecordsByFilter({
paymentAddress: auctionWinner
})
const newRecords = records.filter((record) => {
return record.names !== null && record.names.length > 0
})
for (const record of newRecords) {
if (record.id) {
deployerRecords.push(record)
break
}
}
}
return deployerRecords
}
async releaseDeployerFunds(auctionId: string): Promise<any> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const auction = await registryTransactionWithRetry(() =>
this.registry.releaseFunds(
{
auctionId
},
this.registryConfig.privateKey,
fee
)
)
return auction
}
/**
* Fetch ApplicationDeploymentRecords for deployments
*/
async getDeploymentRecords(
deployments: Deployment[]
): Promise<AppDeploymentRecord[]> {
// Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments
// TODO: Implement Laconicd GQL query to filter records by multiple values for an attribute
const records = await this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE
},
true
)
// Filter records with ApplicationDeploymentRequestId ID
return records.filter((record: AppDeploymentRecord) =>
deployments.some(
(deployment) =>
deployment.applicationDeploymentRequestId ===
record.attributes.request
)
)
}
/**
* Fetch WebappDeployer Records by filter
*/
async getDeployerRecordsByFilter(filter: { [key: string]: any }): Promise<
DeployerRecord[]
> {
return this.registry.queryRecords(
{
type: WEBAPP_DEPLOYER_RECORD_TYPE,
...filter
},
true
)
}
/**
* Fetch ApplicationDeploymentRecords by filter
*/
async getDeploymentRecordsByFilter(filter: { [key: string]: any }): Promise<
AppDeploymentRecord[]
> {
return this.registry.queryRecords(
{
type: APP_DEPLOYMENT_RECORD_TYPE,
...filter
},
true
)
}
/**
* Fetch ApplicationDeploymentRemovalRecords for deployments
*/
async getDeploymentRemovalRecords(
deployments: Deployment[]
): Promise<AppDeploymentRemovalRecord[]> {
// Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments
const records = await this.registry.queryRecords(
{
type: APP_DEPLOYMENT_REMOVAL_RECORD_TYPE
},
true
)
// Filter records with ApplicationDeploymentRecord and ApplicationDeploymentRemovalRequest IDs
return records.filter((record: AppDeploymentRemovalRecord) =>
deployments.some(
(deployment) =>
deployment.applicationDeploymentRemovalRequestId ===
record.attributes.request &&
deployment.applicationDeploymentRecordId ===
record.attributes.deployment
)
)
}
/**
* Fetch record by Id
*/
async getRecordById(id: string): Promise<RegistryRecord | null> {
const [record] = await this.registry.getRecordsByIds([id])
return record ?? null
}
async createApplicationDeploymentRemovalRequest(data: {
deploymentId: string
deployerLrn: string
auctionId?: string | null
payment?: string | null
}): Promise<{
applicationDeploymentRemovalRequestId: string
applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest
}> {
const applicationDeploymentRemovalRequest = {
type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE,
version: '1.0.0',
deployment: data.deploymentId,
deployer: data.deployerLrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment })
}
const result = await this.publishRecord(applicationDeploymentRemovalRequest)
log(`Application deployment removal request record published: ${result.id}`)
log(
'Application deployment removal request data:',
applicationDeploymentRemovalRequest
)
return {
applicationDeploymentRemovalRequestId: result.id,
applicationDeploymentRemovalRequestData:
applicationDeploymentRemovalRequest
}
}
async getCompletedAuctionIds(auctionIds: string[]): Promise<string[]> {
if (auctionIds.length === 0) {
return []
}
const auctions = await this.registry.getAuctionsByIds(auctionIds)
const completedAuctions = auctions
.filter(
(auction: { id: string; status: string }) =>
auction.status === 'completed'
)
.map((auction: { id: string; status: string }) => auction.id)
return completedAuctions
}
async publishRecord(recordData: any): Promise<any> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const result = await registryTransactionWithRetry(() =>
this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: recordData,
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
)
)
return result
}
async getRecordsByName(name: string): Promise<any> {
return this.registry.resolveNames([name])
}
async getAuctionData(auctionId: string): Promise<any> {
return this.registry.getAuctionsByIds([auctionId])
}
async sendTokensToAccount(
receiverAddress: string,
amount: string
): Promise<DeliverTxResponse> {
const fee = parseGasAndFees(
this.registryConfig.fee.gas,
this.registryConfig.fee.fees
)
const account = await this.getAccount()
const laconicClient = await this.registry.getLaconicClient(account)
const txResponse: DeliverTxResponse = await registryTransactionWithRetry(
() =>
laconicClient.sendTokens(
account.address,
receiverAddress,
[
{
denom: 'alnt',
amount
}
],
fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER
)
)
return txResponse
}
async getAccount(): Promise<Account> {
const account = new Account(
Buffer.from(this.registryConfig.privateKey, 'hex')
)
await account.init()
return account
}
async getTxResponse(txHash: string): Promise<IndexedTx | null> {
const account = await this.getAccount()
const laconicClient = await this.registry.getLaconicClient(account)
const txResponse: IndexedTx | null = await laconicClient.getTx(txHash)
return txResponse
}
getLrn(appName: string): string {
assert(this.registryConfig.authority, "Authority doesn't exist")
return `lrn://${this.registryConfig.authority}/applications/${appName}`
}
async generateConfigHash(
environmentVariables: { [key: string]: string },
requesterAddress: string,
pubKey: string,
url: string
): Promise<string> {
// Config to be encrypted
const config = {
authorized: [requesterAddress],
config: { env: environmentVariables }
}
// Serialize the config
const serialized = JSON.stringify(config, null, 2)
const armoredKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n${pubKey}\n\n-----END PGP PUBLIC KEY BLOCK-----`
const publicKey = await openpgp.readKey({ armoredKey })
// Encrypt the config
const encrypted = await openpgp.encrypt({
message: await openpgp.createMessage({ text: serialized }),
encryptionKeys: publicKey,
format: 'binary'
})
// Get the hash after uploading encrypted config
const response = await fetch(`${url}/upload/config`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: encrypted
})
const configHash = await response.json()
return configHash.id
}
}

View File

@ -0,0 +1,413 @@
import debug from 'debug'
import type { DeepPartial, FindOptionsWhere } from 'typeorm'
import type { Domain } from './entity/Domain'
import type { EnvironmentVariable } from './entity/EnvironmentVariable'
import type { Project } from './entity/Project'
import type { Permission } from './entity/ProjectMember'
import type { Service } from './service'
import type {
AddProjectFromTemplateInput,
AuctionParams,
EnvironmentVariables
} from './types'
const log = debug('snowball:resolver')
export const createResolvers = async (service: Service): Promise<any> => {
return {
Query: {
// TODO: add custom type for context
user: (_: any, __: any, context: any) => {
return context.user
},
organizations: async (_: any, __: any, context: any) => {
return service.getOrganizationsByUserId(context.user)
},
project: async (
_: any,
{ projectId }: { projectId: string },
context: any
) => {
return service.getProjectById(context.user, projectId)
},
projectsInOrganization: async (
_: any,
{ organizationSlug }: { organizationSlug: string },
context: any
) => {
return service.getProjectsInOrganization(context.user, organizationSlug)
},
deployments: async (_: any, { projectId }: { projectId: string }) => {
return service.getNonCanonicalDeploymentsByProjectId(projectId)
},
environmentVariables: async (
_: any,
{ projectId }: { projectId: string }
) => {
return service.getEnvironmentVariablesByProjectId(projectId)
},
projectMembers: async (_: any, { projectId }: { projectId: string }) => {
return service.getProjectMembersByProjectId(projectId)
},
searchProjects: async (
_: any,
{ searchText }: { searchText: string },
context: any
) => {
return service.searchProjects(context.user, searchText)
},
domains: async (
_: any,
{
projectId,
filter
}: { projectId: string; filter?: FindOptionsWhere<Domain> }
) => {
return service.getDomainsByProjectId(projectId, filter)
},
getAuctionData: async (_: any, { auctionId }: { auctionId: string }) => {
return service.getAuctionData(auctionId)
},
deployers: async (_: any, __: any) => {
return service.getDeployers()
},
address: async (_: any, __: any) => {
return service.getAddress()
},
verifyTx: async (
_: any,
{
txHash,
amount,
senderAddress
}: { txHash: string; amount: string; senderAddress: string }
) => {
return service.verifyTx(txHash, amount, senderAddress)
},
latestDNSRecord: async (_: any, { projectId }: { projectId: string }) => {
return service.getLatestDNSRecordByProjectId(projectId)
}
},
// TODO: Return error in GQL response
Mutation: {
removeProjectMember: async (
_: any,
{ projectMemberId }: { projectMemberId: string },
context: any
) => {
try {
return await service.removeProjectMember(
context.user,
projectMemberId
)
} catch (err) {
log(err)
return false
}
},
updateProjectMember: async (
_: any,
{
projectMemberId,
data
}: {
projectMemberId: string
data: {
permissions: Permission[]
}
}
) => {
try {
return await service.updateProjectMember(projectMemberId, data)
} catch (err) {
log(err)
return false
}
},
addProjectMember: async (
_: any,
{
projectId,
data
}: {
projectId: string
data: {
email: string
permissions: Permission[]
}
}
) => {
try {
return Boolean(await service.addProjectMember(projectId, data))
} catch (err) {
log(err)
return false
}
},
addEnvironmentVariables: async (
_: any,
{
projectId,
data
}: {
projectId: string
data: { environments: string[]; key: string; value: string }[]
}
) => {
try {
return Boolean(await service.addEnvironmentVariables(projectId, data))
} catch (err) {
log(err)
return false
}
},
updateEnvironmentVariable: async (
_: any,
{
environmentVariableId,
data
}: {
environmentVariableId: string
data: DeepPartial<EnvironmentVariable>
}
) => {
try {
return await service.updateEnvironmentVariable(
environmentVariableId,
data
)
} catch (err) {
log(err)
return false
}
},
removeEnvironmentVariable: async (
_: any,
{ environmentVariableId }: { environmentVariableId: string }
) => {
try {
return await service.removeEnvironmentVariable(environmentVariableId)
} catch (err) {
log(err)
return false
}
},
updateDeploymentToProd: async (
_: any,
{ deploymentId }: { deploymentId: string },
context: any
) => {
try {
return Boolean(
await service.updateDeploymentToProd(context.user, deploymentId)
)
} catch (err) {
log(err)
return false
}
},
addProjectFromTemplate: async (
_: any,
{
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
}: {
organizationSlug: string
data: AddProjectFromTemplateInput
lrn: string
auctionParams: AuctionParams
environmentVariables: EnvironmentVariables[]
},
context: any
) => {
try {
return await service.addProjectFromTemplate(
context.user,
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
)
} catch (err) {
log(err)
throw err
}
},
addProject: async (
_: any,
{
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
}: {
organizationSlug: string
data: DeepPartial<Project>
lrn: string
auctionParams: AuctionParams
environmentVariables: EnvironmentVariables[]
},
context: any
) => {
try {
return await service.addProject(
context.user,
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
)
} catch (err) {
log(err)
throw err
}
},
updateProject: async (
_: any,
{ projectId, data }: { projectId: string; data: DeepPartial<Project> }
) => {
try {
return await service.updateProject(projectId, data)
} catch (err) {
log(err)
return false
}
},
redeployToProd: async (
_: any,
{ deploymentId }: { deploymentId: string },
context: any
) => {
try {
return Boolean(
await service.redeployToProd(context.user, deploymentId)
)
} catch (err) {
log(err)
return false
}
},
deleteProject: async (_: any, { projectId }: { projectId: string }) => {
try {
return await service.deleteProject(projectId)
} catch (err) {
log(err)
return false
}
},
deleteDomain: async (_: any, { domainId }: { domainId: string }) => {
try {
return await service.deleteDomain(domainId)
} catch (err) {
log(err)
return false
}
},
rollbackDeployment: async (
_: any,
{ projectId, deploymentId }: { deploymentId: string; projectId: string }
) => {
try {
return await service.rollbackDeployment(projectId, deploymentId)
} catch (err) {
log(err)
return false
}
},
deleteDeployment: async (
_: any,
{ deploymentId }: { deploymentId: string }
) => {
try {
return await service.deleteDeployment(deploymentId)
} catch (err) {
log(err)
return false
}
},
addDomain: async (
_: any,
{ projectId, data }: { projectId: string; data: { name: string } }
) => {
try {
return Boolean(await service.addDomain(projectId, data))
} catch (err) {
log(err)
return false
}
},
updateDomain: async (
_: any,
{ domainId, data }: { domainId: string; data: DeepPartial<Domain> }
) => {
try {
return await service.updateDomain(domainId, data)
} catch (err) {
log(err)
return false
}
},
authenticateGitHub: async (
_: any,
{ code }: { code: string },
context: any
) => {
try {
return await service.authenticateGitHub(code, context.user)
} catch (err) {
log(err)
return false
}
},
unauthenticateGitHub: async (_: any, __: object, context: any) => {
try {
return service.unauthenticateGitHub(context.user, {
gitHubToken: null
})
} catch (err) {
log(err)
return false
}
}
}
}
}

View File

@ -0,0 +1,97 @@
import { Router } from 'express'
import { SiweMessage } from 'siwe'
import type { Service } from '../service'
import { authenticateUser, createUser } from '../turnkey-backend'
const router: Router = Router()
//
// Turnkey
//
router.get('/registration/:email', async (req, res) => {
const service: Service = req.app.get('service')
const user = await service.getUserByEmail(req.params.email)
if (user) {
return res.send({ subOrganizationId: user?.subOrgId })
}
return res.sendStatus(204)
})
router.post('/register', async (req, res) => {
console.log('Register', req.body)
const { email, challenge, attestation } = req.body
const user = await createUser(req.app.get('service'), {
challenge,
attestation,
userEmail: email,
userName: email.split('@')[0]
})
req.session.address = user.id
res.sendStatus(200)
})
router.post('/authenticate', async (req, res) => {
console.log('Authenticate', req.body)
const { signedWhoamiRequest } = req.body
const user = await authenticateUser(
req.app.get('service'),
signedWhoamiRequest
)
if (user) {
req.session.address = user.id
res.sendStatus(200)
} else {
res.sendStatus(401)
}
})
//
// SIWE Auth
//
router.post('/validate', async (req, res) => {
const { message, signature } = req.body
const { success, data } = await new SiweMessage(message).verify({
signature
})
if (!success) {
return res.send({ success })
}
const service: Service = req.app.get('service')
const user = await service.getUserByEthAddress(data.address)
if (!user) {
const newUser = await service.createUser({
ethAddress: data.address,
email: `${data.address}@example.com`,
subOrgId: '',
turnkeyWalletId: ''
})
// SIWESession from the web3modal library requires both address and chain ID
req.session.address = newUser.id
req.session.chainId = data.chainId
} else {
req.session.address = user.id
req.session.chainId = data.chainId
}
res.send({ success })
})
//
// General
//
router.get('/session', (req, res) => {
if (req.session.address && req.session.chainId) {
res.send({
address: req.session.address,
chainId: req.session.chainId
})
} else {
res.status(401).send({ error: 'Unauthorized: No active session' })
}
})
export default router

View File

@ -0,0 +1,26 @@
import debug from 'debug'
import { Router } from 'express'
import type { Service } from '../service'
const log = debug('snowball:routes-github')
const router: Router = Router()
/* POST GitHub webhook handler */
// https://docs.github.com/en/webhooks/using-webhooks/handling-webhook-deliveries#javascript-example
router.post('/webhook', async (req, res) => {
// Server should respond with a 2XX response within 10 seconds of receiving a webhook delivery
// If server takes longer than that to respond, then GitHub terminates the connection and considers the delivery a failure
res.status(202).send('Accepted')
const service = req.app.get('service') as Service
const githubEvent = req.headers['x-github-event']
log(`Received GitHub webhook for event ${githubEvent}`)
if (githubEvent === 'push') {
// Create deployments using push event data
await service.handleGitHubPush(req.body)
}
})
export default router

View File

@ -0,0 +1,9 @@
import { Router } from 'express'
const router: Router = Router()
router.get('/version', async (_req, res) => {
return res.send({ version: '0.0.9' })
})
export default router

337
apps/backend/src/schema.gql Normal file
View File

@ -0,0 +1,337 @@
enum Role {
Owner
Maintainer
Reader
}
enum Permission {
View
Edit
}
enum Environment {
Production
Preview
Development
}
enum DeploymentStatus {
Building
Ready
Error
Deleting
}
enum AuctionStatus {
completed
reveal
commit
expired
}
enum DomainStatus {
Live
Pending
}
type User {
id: String!
name: String
email: String!
organizations: [Organization!]
projects: [Project!]
isVerified: Boolean!
createdAt: String!
updatedAt: String!
gitHubToken: String
}
type Organization {
id: String!
name: String!
slug: String!
projects: [Project!]
createdAt: String!
updatedAt: String!
members: [OrganizationMember!]
}
type OrganizationMember {
id: String!
member: User!
role: Role!
createdAt: String!
updatedAt: String!
}
type Project {
id: String!
owner: User!
deployments: [Deployment!]
name: String!
repository: String!
prodBranch: String!
description: String
deployers: [Deployer!]
auctionId: String
fundsReleased: Boolean
template: String
framework: String
paymentAddress: String!
txHash: String!
webhooks: [String!]
members: [ProjectMember!]
environmentVariables: [EnvironmentVariable!]
createdAt: String!
updatedAt: String!
organization: Organization!
icon: String
baseDomains: [String!]
}
type ProjectMember {
id: String!
member: User!
permissions: [Permission!]!
isPending: Boolean!
createdAt: String!
updatedAt: String!
}
type Deployment {
id: String!
branch: String!
commitHash: String!
commitMessage: String!
url: String
environment: Environment!
deployer: Deployer
applicationDeploymentRequestId: String
applicationDeploymentRecordData: AppDeploymentRecordAttributes
isCurrent: Boolean!
baseDomain: String
status: DeploymentStatus!
createdAt: String!
updatedAt: String!
createdBy: User!
}
type Domain {
id: String!
branch: String!
name: String!
redirectTo: Domain
status: DomainStatus!
createdAt: String!
updatedAt: String!
}
type EnvironmentVariable {
id: String!
environment: Environment!
key: String!
value: String!
createdAt: String!
updatedAt: String!
}
type Deployer {
deployerLrn: String!
deployerId: String!
deployerApiUrl: String!
minimumPayment: String
paymentAddress: String
createdAt: String!
updatedAt: String!
baseDomain: String
}
type AuthResult {
token: String!
}
input AddEnvironmentVariableInput {
environments: [Environment!]!
key: String!
value: String!
}
input AddProjectFromTemplateInput {
templateOwner: String!
templateRepo: String!
owner: String!
name: String!
isPrivate: Boolean!
paymentAddress: String!
txHash: String!
}
input AddProjectInput {
name: String!
repository: String!
prodBranch: String!
template: String
paymentAddress: String!
txHash: String!
}
input UpdateProjectInput {
name: String
description: String
prodBranch: String
organizationId: String
webhooks: [String!]
}
input AddDomainInput {
name: String!
}
input UpdateDomainInput {
name: String
branch: String
redirectToId: String
}
input UpdateEnvironmentVariableInput {
key: String
value: String
}
input AddProjectMemberInput {
email: String!
permissions: [Permission!]
}
input UpdateProjectMemberInput {
permissions: [Permission]
}
input FilterDomainsInput {
branch: String
status: DomainStatus
}
type Fee {
type: String!
quantity: String!
}
type Bid {
auctionId: String!
bidderAddress: String!
status: String!
commitHash: String!
commitTime: String
commitFee: Fee
revealTime: String
revealFee: Fee
bidAmount: Fee
}
type Auction {
id: String!
kind: String!
status: String!
ownerAddress: String!
createTime: String!
commitsEndTime: String!
revealsEndTime: String!
commitFee: Fee!
revealFee: Fee!
minimumBid: Fee
winnerAddresses: [String!]!
winnerBids: [Fee!]
winnerPrice: Fee
maxPrice: Fee
numProviders: Int!
fundsReleased: Boolean!
bids: [Bid!]!
}
type DNSRecordAttributes {
name: String
value: String
request: String
resourceType: String
version: String
}
type AppDeploymentRecordAttributes {
application: String
auction: String
deployer: String
dns: String
meta: String
name: String
request: String
type: String
url: String
version: String
}
input AuctionParams {
maxPrice: String
numProviders: Int
}
type Query {
user: User!
organizations: [Organization!]
projects: [Project!]
projectsInOrganization(organizationSlug: String!): [Project!]
project(projectId: String!): Project
deployments(projectId: String!): [Deployment!]
environmentVariables(projectId: String!): [EnvironmentVariable!]
projectMembers(projectId: String!): [ProjectMember!]
searchProjects(searchText: String!): [Project!]
getAuctionData(auctionId: String!): Auction!
latestDNSRecord(projectId: String!): DNSRecordAttributes
domains(projectId: String!, filter: FilterDomainsInput): [Domain]
deployers: [Deployer]
address: String!
verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean!
}
type Mutation {
addProjectMember(projectId: String!, data: AddProjectMemberInput): Boolean!
updateProjectMember(
projectMemberId: String!
data: UpdateProjectMemberInput
): Boolean!
removeProjectMember(projectMemberId: String!): Boolean!
addEnvironmentVariables(
projectId: String!
data: [AddEnvironmentVariableInput!]
): Boolean!
updateEnvironmentVariable(
environmentVariableId: String!
data: UpdateEnvironmentVariableInput!
): Boolean!
removeEnvironmentVariable(environmentVariableId: String!): Boolean!
updateDeploymentToProd(deploymentId: String!): Boolean!
addProjectFromTemplate(
organizationSlug: String!
data: AddProjectFromTemplateInput
lrn: String
auctionParams: AuctionParams
environmentVariables: [AddEnvironmentVariableInput!]
): Project!
addProject(
organizationSlug: String!
data: AddProjectInput!
lrn: String
auctionParams: AuctionParams
environmentVariables: [AddEnvironmentVariableInput!]
): Project!
updateProject(projectId: String!, data: UpdateProjectInput): Boolean!
redeployToProd(deploymentId: String!): Boolean!
deleteProject(projectId: String!): Boolean!
deleteDomain(domainId: String!): Boolean!
rollbackDeployment(projectId: String!, deploymentId: String!): Boolean!
deleteDeployment(deploymentId: String!): Boolean!
addDomain(projectId: String!, data: AddDomainInput!): Boolean!
updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean!
authenticateGitHub(code: String!): AuthResult!
unauthenticateGitHub: Boolean!
}

130
apps/backend/src/server.ts Normal file
View File

@ -0,0 +1,130 @@
import {
ApolloServerPluginDrainHttpServer,
ApolloServerPluginLandingPageLocalDefault,
AuthenticationError
} from 'apollo-server-core'
import { ApolloServer } from 'apollo-server-express'
import cors from 'cors'
import debug from 'debug'
import express from 'express'
import session from 'express-session'
import { createServer } from 'node:http'
import { makeExecutableSchema } from '@graphql-tools/schema'
import type { TypeSource } from '@graphql-tools/utils'
import type { ServerConfig } from './config'
import authRouter from './routes/auth'
import githubRouter from './routes/github'
import stagingRouter from './routes/staging'
import type { Service } from './service'
const log = debug('snowball:server')
// Set cookie expiration to 1 month in milliseconds
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000
declare module 'express-session' {
interface SessionData {
address: string
chainId: number
}
}
export const createAndStartServer = async (
serverConfig: ServerConfig,
typeDefs: TypeSource,
resolvers: any,
service: Service
): Promise<ApolloServer> => {
const { host, port, gqlPath = '/graphql' } = serverConfig
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session
const app = express()
// Create HTTP server
const httpServer = createServer(app)
// Create the schema
const schema = makeExecutableSchema({
typeDefs,
resolvers
})
const server = new ApolloServer({
schema,
csrfPrevention: true,
context: async ({ req }) => {
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
const { address } = req.session
if (!address) {
throw new AuthenticationError('Unauthorized: No active session')
}
const user = await service.getUser(address)
return { user }
},
plugins: [
// Proper shutdown for the HTTP server
ApolloServerPluginDrainHttpServer({ httpServer }),
ApolloServerPluginLandingPageLocalDefault({ embed: true })
]
})
await server.start()
app.use(
cors({
origin: appOriginUrl,
credentials: true
})
)
const sessionOptions: session.SessionOptions = {
secret: secret,
resave: false,
saveUninitialized: true,
cookie: {
secure: new URL(appOriginUrl).protocol === 'https:',
maxAge: COOKIE_MAX_AGE,
domain: domain || undefined,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax'
}
}
if (trustProxy) {
// trust first proxy
app.set('trust proxy', 1)
}
app.use(session(sessionOptions))
server.applyMiddleware({
app: app as any,
path: gqlPath,
cors: {
origin: [appOriginUrl],
credentials: true
}
})
app.use(express.json())
app.set('service', service)
app.use('/auth', authRouter)
app.use('/api/github', githubRouter)
app.use('/staging', stagingRouter)
app.use((err: any, _req: any, res: any, _next: any) => {
console.error(err)
res.status(500).json({ error: err.message })
})
httpServer.listen(port, host, () => {
log(`Server is listening on ${host}:${port}${server.graphqlPath}`)
})
return server
}

1783
apps/backend/src/service.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,130 @@
import { Turnkey, type TurnkeyApiTypes } from '@turnkey/sdk-server'
// Default path for the first Ethereum address in a new HD wallet.
// See https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki, paths are in the form:
// m / purpose' / coin_type' / account' / change / address_index
// - Purpose is a constant set to 44' following the BIP43 recommendation.
// - Coin type is set to 60 (ETH) -- see https://github.com/satoshilabs/slips/blob/master/slip-0044.md
// - Account, Change, and Address Index are set to 0
import { DEFAULT_ETHEREUM_ACCOUNTS } from '@turnkey/sdk-server'
import type { Service } from './service'
import { getConfig } from './utils'
type TAttestation = TurnkeyApiTypes['v1Attestation']
type CreateUserParams = {
userName: string
userEmail: string
challenge: string
attestation: TAttestation
}
export async function createUser(
service: Service,
{ userName, userEmail, challenge, attestation }: CreateUserParams
) {
try {
if (await service.getUserByEmail(userEmail)) {
throw new Error(`User already exists: ${userEmail}`)
}
const config = await getConfig()
const turnkey = new Turnkey(config.turnkey)
const apiClient = turnkey.api()
const walletName = 'Default ETH Wallet'
const createSubOrgResponse = await apiClient.createSubOrganization({
subOrganizationName: `Default SubOrg for ${userEmail}`,
rootQuorumThreshold: 1,
rootUsers: [
{
userName,
userEmail,
apiKeys: [],
authenticators: [
{
authenticatorName: 'Passkey',
challenge,
attestation
}
]
}
],
wallet: {
walletName: walletName,
accounts: DEFAULT_ETHEREUM_ACCOUNTS
}
})
const subOrgId = refineNonNull(createSubOrgResponse.subOrganizationId)
const wallet = refineNonNull(createSubOrgResponse.wallet)
const result = {
id: wallet.walletId,
address: wallet.addresses[0],
subOrgId: subOrgId
}
console.log('Turnkey success', result)
const user = await service.createUser({
name: userName,
email: userEmail,
subOrgId,
ethAddress: wallet.addresses[0],
turnkeyWalletId: wallet.walletId
})
console.log('New user', user)
return user
} catch (e) {
console.error('Failed to create user:', e)
throw e
}
}
export async function authenticateUser(
service: Service,
signedWhoamiRequest: {
url: string
body: any
stamp: {
stampHeaderName: string
stampHeaderValue: string
}
}
) {
try {
const tkRes = await fetch(signedWhoamiRequest.url, {
method: 'POST',
body: signedWhoamiRequest.body,
headers: {
[signedWhoamiRequest.stamp.stampHeaderName]:
signedWhoamiRequest.stamp.stampHeaderValue
}
})
console.log('AUTH RESULT', tkRes.status)
if (tkRes.status !== 200) {
console.log(await tkRes.text())
return null
}
const orgId = (await tkRes.json()).organizationId
const user = await service.getUserBySubOrgId(orgId)
return user
} catch (e) {
console.error('Failed to authenticate:', e)
throw e
}
}
function refineNonNull<T>(
input: T | null | undefined,
errorMessage?: string
): T {
if (input == null) {
throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`)
}
return input
}

124
apps/backend/src/types.ts Normal file
View File

@ -0,0 +1,124 @@
export interface PackageJSON {
name: string
version: string
author?: string
description?: string
homepage?: string
license?: string
repository?: string
}
export interface GitRepositoryDetails {
id: number
name: string
full_name: string
visibility?: string
updated_at?: string | null
default_branch?: string
}
export interface GitPushEventPayload {
repository: GitRepositoryDetails
ref: string
head_commit: {
id: string
message: string
}
deleted: boolean
}
export interface AppDeploymentRecordAttributes {
application: string
auction: string
deployer: string
dns: string
meta: string
name: string
request: string
type: string
url: string
version: string
}
export interface DNSRecordAttributes {
name: string
value: string
request: string
resourceType: string
version: string
}
export interface RegistryDNSRecordAttributes {
name: string
value: string
request: string
resource_type: string
version: string
}
export interface AppDeploymentRemovalRecordAttributes {
deployment: string
request: string
type: 'ApplicationDeploymentRemovalRecord'
version: string
}
export interface RegistryRecord {
id: string
names: string[] | null
owners: string[]
bondId: string
createTime: string
expiryTime: string
}
export interface AppDeploymentRecord extends RegistryRecord {
attributes: AppDeploymentRecordAttributes
}
export interface AppDeploymentRemovalRecord extends RegistryRecord {
attributes: AppDeploymentRemovalRecordAttributes
}
export interface DNSRecord extends RegistryRecord {
attributes: RegistryDNSRecordAttributes
}
export interface AddProjectFromTemplateInput {
templateOwner: string
templateRepo: string
owner: string
name: string
isPrivate: boolean
paymentAddress: string
txHash: string
}
export interface AuctionParams {
maxPrice: string
numProviders: number
}
export interface EnvironmentVariables {
environments: string[]
key: string
value: string
}
export interface DeployerRecord {
id: string
names: string[]
owners: string[]
bondId: string
createTime: string
expiryTime: string
attributes: {
apiUrl: string
minimumPayment: string | null
name: string
paymentAddress: string
publicKey: string
type: string
version: string
}
}

160
apps/backend/src/utils.ts Normal file
View File

@ -0,0 +1,160 @@
import debug from 'debug'
import fs from 'fs-extra'
import assert from 'node:assert'
import path from 'node:path'
import type { Octokit } from 'octokit'
import toml from 'toml'
import type {
DataSource,
DeepPartial,
EntityTarget,
ObjectLiteral
} from 'typeorm'
import type { Config } from './config'
interface PackageJSON {
name: string
description?: string
homepage?: string
license?: string
author?: string | { [key: string]: unknown }
version?: string
[key: string]: unknown
}
const log = debug('snowball:utils')
export async function getConfig() {
return await _getConfig<Config>(
path.join(__dirname, '../environments/local.toml')
)
}
const _getConfig = async <ConfigType>(
configFile: string
): Promise<ConfigType> => {
const fileExists = await fs.pathExists(configFile)
if (!fileExists) {
throw new Error(`Config file not found: ${configFile}`)
}
const config = toml.parse(await fs.readFile(configFile, 'utf8'))
log('config', JSON.stringify(config, null, 2))
return config
}
export const checkFileExists = async (filePath: string): Promise<boolean> => {
try {
await fs.access(filePath, fs.constants.F_OK)
return true
} catch (err) {
log(err)
return false
}
}
export const getEntities = async (filePath: string): Promise<any> => {
const entitiesData = await fs.readFile(filePath, 'utf-8')
const entities = JSON.parse(entitiesData)
return entities
}
export const loadAndSaveData = async <Entity extends ObjectLiteral>(
entityType: EntityTarget<Entity>,
dataSource: DataSource,
entities: any,
relations?: any | undefined
): Promise<Entity[]> => {
const entityRepository = dataSource.getRepository(entityType)
const savedEntity: Entity[] = []
for (const entityData of entities) {
let entity = entityRepository.create(entityData as DeepPartial<Entity>)
if (relations) {
for (const field in relations) {
const valueIndex = `${field}Index`
entity = {
...entity,
[field]: relations[field][entityData[valueIndex]]
}
}
}
const dbEntity = await entityRepository.save(entity)
savedEntity.push(dbEntity)
}
return savedEntity
}
export const sleep = async (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
export const getRepoDetails = async (
octokit: Octokit,
repository: string,
commitHash: string | undefined
): Promise<{
repo: string
packageJSON: PackageJSON
repoUrl: string
}> => {
const [owner, repo] = repository.split('/')
const { data: packageJSONData } = await octokit.rest.repos.getContent({
owner,
repo,
path: 'package.json',
ref: commitHash
})
if (!packageJSONData) {
throw new Error('Package.json file not found')
}
assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file')
const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content))
assert(packageJSON.name, "name field doesn't exist in package.json")
const repoUrl = (
await octokit.rest.repos.get({
owner,
repo
})
).data.html_url
return {
repo,
packageJSON,
repoUrl
}
}
// Wrapper method for registry txs to retry once if 'account sequence mismatch' occurs
export const registryTransactionWithRetry = async (
txMethod: () => Promise<any>
): Promise<any> => {
try {
return await txMethod()
} catch (error: any) {
if (!error.message.includes('account sequence mismatch')) {
throw error
}
console.error(
'Transaction failed due to account sequence mismatch. Retrying...'
)
try {
return await txMethod()
} catch (retryError: any) {
throw new Error(
`Transaction failed again after retry: ${retryError.message}`
)
}
}
}

View File

@ -0,0 +1,19 @@
import * as fs from 'node:fs/promises'
import debug from 'debug'
import { getConfig } from '../src/utils'
const log = debug('snowball:delete-database')
const deleteFile = async (filePath: string) => {
await fs.unlink(filePath)
log(`File ${filePath} has been deleted.`)
}
const main = async () => {
const config = await getConfig()
deleteFile(config.database.dbPath)
}
main().catch((err) => log(err))

View File

@ -0,0 +1,189 @@
[
{
"projectIndex": 0,
"domainIndex": 0,
"createdByIndex": 0,
"id": "ffhae3zq",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "qbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "xqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-ffhae3zq.snowball.xyz"
},
{
"projectIndex": 0,
"domainIndex": 1,
"createdByIndex": 0,
"id": "vehagei8",
"status": "Ready",
"environment": "Preview",
"isCurrent": false,
"applicationRecordId": "wbafyreihvzya6ovp4yfpkqnddkui2iw7thbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "wqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-vehagei8.snowball.xyz"
},
{
"projectIndex": 0,
"domainIndex": 2,
"createdByIndex": 0,
"id": "qmgekyte",
"status": "Ready",
"environment": "Development",
"isCurrent": false,
"applicationRecordId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "kqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-qmgekyte.snowball.xyz"
},
{
"projectIndex": 0,
"domainIndex": null,
"createdByIndex": 0,
"id": "f8wsyim6",
"status": "Ready",
"environment": "Production",
"isCurrent": false,
"applicationRecordId": "rbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhw74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "yqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "prod",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-f8wsyim6.snowball.xyz"
},
{
"projectIndex": 1,
"domainIndex": 3,
"createdByIndex": 1,
"id": "eO8cckxk",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "tbafyreihvzya6ovp4yfpqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-2-eO8cckxk.snowball.xyz"
},
{
"projectIndex": 1,
"domainIndex": 4,
"createdByIndex": 1,
"id": "yaq0t5yw",
"status": "Ready",
"environment": "Preview",
"isCurrent": false,
"applicationRecordId": "ybafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "tqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-2-yaq0t5yw.snowball.xyz"
},
{
"projectIndex": 1,
"domainIndex": 5,
"createdByIndex": 1,
"id": "hwwr6sbx",
"status": "Ready",
"environment": "Development",
"isCurrent": false,
"applicationRecordId": "ubafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "eqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "testProject-2-hwwr6sbx.snowball.xyz"
},
{
"projectIndex": 2,
"domainIndex": 9,
"createdByIndex": 2,
"id": "ndxje48a",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "ibayreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "dqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "main",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-ndxje48a.snowball.xyz"
},
{
"projectIndex": 2,
"domainIndex": 7,
"createdByIndex": 2,
"id": "gtgpgvei",
"status": "Ready",
"environment": "Preview",
"isCurrent": false,
"applicationRecordId": "obafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationRecordData": {},
"applicationDeploymentRequestId": "aqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-gtgpgvei.snowball.xyz"
},
{
"projectIndex": 2,
"domainIndex": 8,
"createdByIndex": 2,
"id": "b4bpthjr",
"status": "Ready",
"environment": "Development",
"isCurrent": false,
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
"applicationRecordData": {},
"applicationDeploymentRequestId": "uqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-b4bpthjr.snowball.xyz"
},
{
"projectIndex": 3,
"domainIndex": 6,
"createdByIndex": 2,
"id": "b4bpthjr",
"status": "Ready",
"environment": "Production",
"isCurrent": true,
"applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo",
"applicationRecordData": {},
"applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi",
"applicationDeploymentRequestData": {},
"branch": "test",
"commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00",
"commitMessage": "subscription added",
"url": "iglootools-b4bpthjr.snowball.xyz"
}
]

View File

@ -0,0 +1,92 @@
[
{
"projectIndex": 0,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 0,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 0,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 1,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 1,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 1,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 2,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 2,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 2,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 3,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 3,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 3,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
},
{
"projectIndex": 4,
"key": "ABC",
"value": "ABC",
"environment": "Production"
},
{
"projectIndex": 4,
"key": "ABC",
"value": "ABC",
"environment": "Preview"
},
{
"projectIndex": 4,
"key": "XYZ",
"value": "abc3",
"environment": "Preview"
}
]

View File

@ -0,0 +1,7 @@
[
{
"id": "2379cf1f-a232-4ad2-ae14-4d881131cc26",
"name": "Deploy Tools",
"slug": "deploy-tools"
}
]

View File

@ -0,0 +1,44 @@
[
{
"projectIndex": 0,
"name": "example.snowballtools.xyz",
"status": "Live",
"branch": "main"
},
{
"projectIndex": 0,
"name": "example.org",
"status": "Pending",
"branch": "test"
},
{
"projectIndex": 1,
"name": "example.snowballtools.xyz",
"status": "Live",
"branch": "main"
},
{
"projectIndex": 1,
"name": "example.org",
"status": "Pending",
"branch": "test"
},
{
"projectIndex": 2,
"name": "example.snowballtools.xyz",
"status": "Live",
"branch": "main"
},
{
"projectIndex": 2,
"name": "example.org",
"status": "Pending",
"branch": "test"
},
{
"projectIndex": 3,
"name": "iglootools-2.com",
"status": "Pending",
"branch": "test"
}
]

View File

@ -0,0 +1,56 @@
[
{
"memberIndex": 1,
"projectIndex": 0,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 0,
"permissions": ["View", "Edit"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 1,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 0,
"projectIndex": 2,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 1,
"projectIndex": 2,
"permissions": ["View", "Edit"],
"isPending": false
},
{
"memberIndex": 0,
"projectIndex": 3,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 3,
"permissions": ["View", "Edit"],
"isPending": false
},
{
"memberIndex": 1,
"projectIndex": 4,
"permissions": ["View"],
"isPending": false
},
{
"memberIndex": 2,
"projectIndex": 4,
"permissions": ["View", "Edit"],
"isPending": false
}
]

View File

@ -0,0 +1,67 @@
[
{
"ownerIndex": 0,
"organizationIndex": 0,
"name": "testProject",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test",
"template": "webapp",
"framework": "test",
"webhooks": [],
"icon": "",
"subDomain": "testProject.snowball.xyz"
},
{
"ownerIndex": 1,
"organizationIndex": 0,
"name": "testProject-2",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-2",
"template": "webapp",
"framework": "test-2",
"webhooks": [],
"icon": "",
"subDomain": "testProject-2.snowball.xyz"
},
{
"ownerIndex": 2,
"organizationIndex": 0,
"name": "iglootools",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-3",
"template": "webapp",
"framework": "test-3",
"webhooks": [],
"icon": "",
"subDomain": "iglootools.snowball.xyz"
},
{
"ownerIndex": 1,
"organizationIndex": 0,
"name": "iglootools-2",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-4",
"template": "webapp",
"framework": "test-4",
"webhooks": [],
"icon": "",
"subDomain": "iglootools-2.snowball.xyz"
},
{
"ownerIndex": 0,
"organizationIndex": 1,
"name": "snowball-2",
"repository": "snowball-tools/snowball-ts-framework-template",
"prodBranch": "main",
"description": "test-5",
"template": "webapp",
"framework": "test-5",
"webhooks": [],
"icon": "",
"subDomain": "snowball-2.snowball.xyz"
}
]

View File

@ -0,0 +1,23 @@
[
{
"projectIndex": 0,
"name": "www.example.org",
"status": "Pending",
"redirectToIndex": 1,
"branch": "test"
},
{
"projectIndex": 1,
"name": "www.example.org",
"status": "Pending",
"redirectToIndex": 3,
"branch": "test"
},
{
"projectIndex": 2,
"name": "www.example.org",
"status": "Pending",
"redirectToIndex": 5,
"branch": "test"
}
]

View File

@ -0,0 +1,22 @@
[
{
"role": "Owner",
"memberIndex": 0,
"organizationIndex": 0
},
{
"role": "Maintainer",
"memberIndex": 1,
"organizationIndex": 0
},
{
"role": "Owner",
"memberIndex": 2,
"organizationIndex": 0
},
{
"role": "Owner",
"memberIndex": 0,
"organizationIndex": 1
}
]

23
apps/backend/test/fixtures/users.json vendored Normal file
View File

@ -0,0 +1,23 @@
[
{
"id": "59f4355d-9549-4aac-9b54-eeefceeabef0",
"name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"email": "snowball@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
},
{
"id": "e505b212-8da6-48b2-9614-098225dab34b",
"name": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8",
"email": "alice@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8"
},
{
"id": "cd892fad-9138-4aa2-a62c-414a32776ea7",
"name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
"email": "bob@snowballtools.xyz",
"isVerified": true,
"ethAddress": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a"
}
]

View File

@ -0,0 +1,176 @@
import path from 'node:path'
import debug from 'debug'
import { DataSource } from 'typeorm'
import { Deployment } from '../src/entity/Deployment'
import { Domain } from '../src/entity/Domain'
import { EnvironmentVariable } from '../src/entity/EnvironmentVariable'
import { Organization } from '../src/entity/Organization'
import { Project } from '../src/entity/Project'
import { ProjectMember } from '../src/entity/ProjectMember'
import { User } from '../src/entity/User'
import { UserOrganization } from '../src/entity/UserOrganization'
import {
checkFileExists,
getConfig,
getEntities,
loadAndSaveData
} from '../src/utils'
const log = debug('snowball:initialize-database')
const USER_DATA_PATH = './fixtures/users.json'
const PROJECT_DATA_PATH = './fixtures/projects.json'
const ORGANIZATION_DATA_PATH = './fixtures/organizations.json'
const USER_ORGANIZATION_DATA_PATH = './fixtures/user-organizations.json'
const PROJECT_MEMBER_DATA_PATH = './fixtures/project-members.json'
const PRIMARY_DOMAIN_DATA_PATH = './fixtures/primary-domains.json'
const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json'
const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json'
const REDIRECTED_DOMAIN_DATA_PATH = './fixtures/redirected-domains.json'
const generateTestData = async (dataSource: DataSource) => {
const userEntities = await getEntities(
path.resolve(__dirname, USER_DATA_PATH)
)
const savedUsers = await loadAndSaveData(User, dataSource, userEntities)
const orgEntities = await getEntities(
path.resolve(__dirname, ORGANIZATION_DATA_PATH)
)
const savedOrgs = await loadAndSaveData(Organization, dataSource, orgEntities)
const projectRelations = {
owner: savedUsers,
organization: savedOrgs
}
const projectEntities = await getEntities(
path.resolve(__dirname, PROJECT_DATA_PATH)
)
const savedProjects = await loadAndSaveData(
Project,
dataSource,
projectEntities,
projectRelations
)
const domainRepository = dataSource.getRepository(Domain)
const domainPrimaryRelations = {
project: savedProjects
}
const primaryDomainsEntities = await getEntities(
path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH)
)
const savedPrimaryDomains = await loadAndSaveData(
Domain,
dataSource,
primaryDomainsEntities,
domainPrimaryRelations
)
const domainRedirectedRelations = {
project: savedProjects,
redirectTo: savedPrimaryDomains
}
const redirectDomainsEntities = await getEntities(
path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH)
)
await loadAndSaveData(
Domain,
dataSource,
redirectDomainsEntities,
domainRedirectedRelations
)
const savedDomains = await domainRepository.find()
const userOrganizationRelations = {
member: savedUsers,
organization: savedOrgs
}
const userOrganizationsEntities = await getEntities(
path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH)
)
await loadAndSaveData(
UserOrganization,
dataSource,
userOrganizationsEntities,
userOrganizationRelations
)
const projectMemberRelations = {
member: savedUsers,
project: savedProjects
}
const projectMembersEntities = await getEntities(
path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH)
)
await loadAndSaveData(
ProjectMember,
dataSource,
projectMembersEntities,
projectMemberRelations
)
const deploymentRelations = {
project: savedProjects,
domain: savedDomains,
createdBy: savedUsers
}
const deploymentsEntities = await getEntities(
path.resolve(__dirname, DEPLOYMENT_DATA_PATH)
)
await loadAndSaveData(
Deployment,
dataSource,
deploymentsEntities,
deploymentRelations
)
const environmentVariableRelations = {
project: savedProjects
}
const environmentVariablesEntities = await getEntities(
path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH)
)
await loadAndSaveData(
EnvironmentVariable,
dataSource,
environmentVariablesEntities,
environmentVariableRelations
)
}
const main = async () => {
const config = await getConfig()
const isDbPresent = await checkFileExists(config.database.dbPath)
if (!isDbPresent) {
const dataSource = new DataSource({
type: 'better-sqlite3',
database: config.database.dbPath,
synchronize: true,
logging: true,
entities: [path.join(__dirname, '../src/entity/*')]
})
await dataSource.initialize()
await generateTestData(dataSource)
log('Data loaded successfully')
} else {
log('WARNING: Database already exists')
}
}
main().catch((err) => {
log(err)
})

View File

@ -0,0 +1,49 @@
import debug from 'debug'
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
import { getConfig } from '../src/utils'
const log = debug('snowball:initialize-registry')
const DENOM = 'alnt'
const BOND_AMOUNT = '1000000000'
async function main() {
const { registryConfig } = await getConfig()
// TODO: Get authority names from args
const authorityNames = ['snowballtools', registryConfig.authority]
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
)
const bondId = await registry.getNextBondId(registryConfig.privateKey)
log('bondId:', bondId)
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
await registry.createBond(
{ denom: DENOM, amount: BOND_AMOUNT },
registryConfig.privateKey,
fee
)
for await (const name of authorityNames) {
await registry.reserveAuthority({ name }, registryConfig.privateKey, fee)
log('Reserved authority name:', name)
await registry.setAuthorityBond(
{ name, bondId },
registryConfig.privateKey,
fee
)
log(`Bond ${bondId} set for authority ${name}`)
}
}
main().catch((err) => {
log(err)
})

View File

@ -0,0 +1,100 @@
import path from 'node:path'
import debug from 'debug'
import { DataSource } from 'typeorm'
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
import {
Deployment,
DeploymentStatus,
Environment
} from '../src/entity/Deployment'
import { getConfig } from '../src/utils'
const log = debug('snowball:publish-deploy-records')
async function main() {
const { registryConfig, database } = await getConfig()
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
)
const dataSource = new DataSource({
type: 'better-sqlite3',
database: database.dbPath,
synchronize: true,
entities: [path.join(__dirname, '../src/entity/*')]
})
await dataSource.initialize()
const deploymentRepository = dataSource.getRepository(Deployment)
const deployments = await deploymentRepository.find({
relations: {
project: true
},
where: {
status: DeploymentStatus.Building
}
})
for await (const deployment of deployments) {
const url = `https://${(deployment.project.name).toLowerCase()}-${deployment.id}.${deployment.deployer.baseDomain}`
const applicationDeploymentRecord = {
type: 'ApplicationDeploymentRecord',
version: '0.0.1',
name: deployment.applicationRecordData.name,
application: deployment.applicationRecordId,
// TODO: Create DNS record
dns: 'bafyreihlymqggsgqiqawvehkpr2imt4l3u6q7um7xzjrux5rhsvwnuyewm',
// Using dummy values
meta: JSON.stringify({
config: 'da39a3ee5e6b4b0d3255bfef95601890afd80709',
so: '66fcfa49a1664d4cb4ce4f72c1c0e151'
}),
request: deployment.applicationDeploymentRequestId,
url
}
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
const result = await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRecord,
bondId: registryConfig.bondId
},
'',
fee
)
// Remove deployment for project subdomain if deployment is for production environment
if (deployment.environment === Environment.Production) {
applicationDeploymentRecord.url = `https://${deployment.project.name}.${deployment.deployer.baseDomain}`
await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRecord,
bondId: registryConfig.bondId
},
'',
fee
)
}
log('Application deployment record data:', applicationDeploymentRecord)
log(`Application deployment record published: ${result.id}`)
}
}
main().catch((err) => {
log(err)
})

View File

@ -0,0 +1,70 @@
import path from 'node:path'
import debug from 'debug'
import { DataSource } from 'typeorm'
import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk'
import { Deployment, DeploymentStatus } from '../src/entity/Deployment'
import { getConfig } from '../src/utils'
const log = debug('snowball:publish-deployment-removal-records')
async function main() {
const { registryConfig, database } = await getConfig()
const registry = new Registry(
registryConfig.gqlEndpoint,
registryConfig.restEndpoint,
{ chainId: registryConfig.chainId }
)
const dataSource = new DataSource({
type: 'better-sqlite3',
database: database.dbPath,
synchronize: true,
entities: [path.join(__dirname, '../src/entity/*')]
})
await dataSource.initialize()
const deploymentRepository = dataSource.getRepository(Deployment)
const deployments = await deploymentRepository.find({
relations: {
project: true
},
where: {
status: DeploymentStatus.Deleting
}
})
for await (const deployment of deployments) {
const applicationDeploymentRemovalRecord = {
type: 'ApplicationDeploymentRemovalRecord',
version: '1.0.0',
deployment: deployment.applicationDeploymentRecordId,
request: deployment.applicationDeploymentRemovalRequestId
}
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees)
const result = await registry.setRecord(
{
privateKey: registryConfig.privateKey,
record: applicationDeploymentRemovalRecord,
bondId: registryConfig.bondId
},
'',
fee
)
log(
'Application deployment removal record data:',
applicationDeploymentRemovalRecord
)
log(`Application deployment removal record published: ${result.id}`)
}
}
main().catch((err) => {
log(err)
})

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["dist", "src/**/*.test.ts"]
}

View File

@ -0,0 +1,6 @@
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_PUBLIC_WALLET_IFRAME_URL= # wherever your wallet is running
NEXT_PUBLIC_LACONICD_CHAIN_ID= # the appropriate chain ID for your network
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN=

12
apps/deploy-fe/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules
.next/
.turbo/
.env
.env.local
.env.development.local
.env.test.local
# clerk configuration (can include secrets)
/.clerk/
.vercel
.clerk/

45
apps/deploy-fe/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,45 @@
{
// Project-specific formatter choice
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
// TypeScript configuration
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
// Code actions
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.addMissingImports.ts": "explicit",
"source.organizeImports.biome": "explicit",
"source.removeUnused.ts": "explicit"
},
// Language-specific formatters for this project
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
// TypeScript-specific performance settings for this project
"typescript.preferGoToSourceDefinition": true,
"typescript.suggest.paths": true,
"typescript.tsserver.disableAutomaticTypeAcquisition": false,
// TypeScript server project-specific settings
"typescript.tsserver.maxTsServerMemory": 8192,
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"typescript.tsserver.enableTracing": false,
// For large TypeScript projects
"search.exclude": {
"**/node_modules": true,
"**/dist": true,
"**/build": true
},
// For better JSDoc documentation
"javascript.suggest.completeJSDocs": true
}

View File

@ -0,0 +1,3 @@
{
"extends": ["../../biome.json"]
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "../../services/ui/tailwind.config.ts",
"css": "../../services/ui/src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "@workspace/ui/lib/utils",
"ui": "@workspace/ui/components"
}
}

View File

@ -0,0 +1,16 @@
import dotenv from 'dotenv'
// Load environment variables from .env.development.local
dotenv.config({ path: '.env.development.local' })
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['@workspace/ui'],
env: {
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY
}
}
export default nextConfig

View File

@ -0,0 +1,90 @@
{
"name": "deploy-fe",
"version": "0.0.1",
"type": "module",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--inspect' next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format .",
"format:fix": "biome format --write .",
"check-types": "tsc --noEmit",
"fix-types": "tsc --noEmit --pretty --incremental"
},
"dependencies": {
"@biomejs/biome": "^1.9.4",
"@clerk/nextjs": "^6.12.4",
"@clerk/themes": "^2.2.20",
"@hookform/resolvers": "^4.1.2",
"@octokit/rest": "^21.1.1",
"@octokit/webhooks-types": "^7.6.1",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-visually-hidden": "^1.1.2",
"@workspace/ui": "workspace:*",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "^4.1.0",
"downshift": "^9.0.9",
"embla-carousel-react": "^8.5.2",
"input-otp": "^1.4.2",
"lucide-react": "0.477.0",
"next": "^15.2.1",
"next-themes": "^0.4.4",
"octokit": "^3.1.2",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.1",
"siwe": "^3.0.0",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.2",
"usehooks-ts": "^3.1.1",
"vaul": "^1.1.2",
"zod": "^3.23.8",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.0",
"@types/react-dom": "18.3.1",
"@workspace/gql-client": "workspace:*",
"@workspace/typescript-config": "workspace:*",
"dotenv": "^16.4.7",
"postcss": "^8",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}

View File

@ -0,0 +1 @@
export { default } from '@workspace/ui/postcss.config'

View File

@ -0,0 +1,220 @@
./.env.local
./.gitignore
./.turbo/turbo-build.log
./.vscode/settings.json
./biome.jsonc
./components.json
./next-env.d.ts
./next.config.mjs
./package.json
./postcss.config.mjs
./repo_structure.txt
./src/actions/github.ts
./src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx
./src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx
./src/app/(web3-authenticated)/(dashboard)/home/loading.tsx
./src/app/(web3-authenticated)/(dashboard)/home/page.tsx
./src/app/(web3-authenticated)/(dashboard)/layout.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(configure)/cf/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(deploy)/dp/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(success)/sc/[id]/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(deployments)/dep/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(integrations)/int/GitPage.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(integrations)/int/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(collaborators)/col/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(git)/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/ProjectSettingsPage.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/deployments/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/layout.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/loading.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/page.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/error.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx
./src/app/(web3-authenticated)/(dashboard)/projects/page.tsx
./src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx
./src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx
./src/app/(web3-authenticated)/(dashboard)/store/page.tsx
./src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx
./src/app/(web3-authenticated)/(dashboard)/support/page.tsx
./src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx
./src/app/(web3-authenticated)/layout.tsx
./src/app/actions/github.ts
./src/app/api/auth/route.ts
./src/app/api/github/webhook/route.ts
./src/app/favicon.ico
./src/app/layout.tsx
./src/app/loading.tsx
./src/app/page.tsx
./src/app/sign-in/[[...sign-in]]/page.tsx
./src/components/assets/laconic-mark.tsx
./src/components/core/dropdown/Dropdown.tsx
./src/components/core/dropdown/README.md
./src/components/core/dropdown/index.ts
./src/components/core/dropdown/types.ts
./src/components/core/format-milli-second/FormatMilliSecond.tsx
./src/components/core/format-milli-second/README.md
./src/components/core/format-milli-second/index.ts
./src/components/core/format-milli-second/types.ts
./src/components/core/logo/Logo.tsx
./src/components/core/logo/README.md
./src/components/core/logo/index.ts
./src/components/core/logo/types.ts
./src/components/core/search-bar/README.md
./src/components/core/search-bar/SearchBar.tsx
./src/components/core/search-bar/index.ts
./src/components/core/search-bar/types.ts
./src/components/core/stepper/README.md
./src/components/core/stepper/Stepper.tsx
./src/components/core/stepper/index.ts
./src/components/core/stepper/types.ts
./src/components/core/stop-watch/README.md
./src/components/core/stop-watch/StopWatch.tsx
./src/components/core/stop-watch/index.ts
./src/components/core/stop-watch/types.ts
./src/components/core/vertical-stepper/README.md
./src/components/core/vertical-stepper/VerticalStepper.tsx
./src/components/core/vertical-stepper/index.ts
./src/components/core/vertical-stepper/types.ts
./src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx
./src/components/foundation/coming-soon-overlay/index.ts
./src/components/foundation/github-session-button/GitHubSessionButton.tsx
./src/components/foundation/github-session-button/README.md
./src/components/foundation/github-session-button/index.ts
./src/components/foundation/github-session-button/types.ts
./src/components/foundation/index.ts
./src/components/foundation/laconic-icon/LaconicIcon.tsx
./src/components/foundation/laconic-icon/README.md
./src/components/foundation/laconic-icon/index.ts
./src/components/foundation/laconic-icon/types.ts
./src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx
./src/components/foundation/loading/loading-overlay/README.md
./src/components/foundation/loading/loading-overlay/index.ts
./src/components/foundation/navigation-wrapper/NavigationWrapper.tsx
./src/components/foundation/navigation-wrapper/README.md
./src/components/foundation/navigation-wrapper/index.ts
./src/components/foundation/page-header/PageHeader.tsx
./src/components/foundation/page-header/README.md
./src/components/foundation/page-header/index.ts
./src/components/foundation/page-wrapper/PageWrapper.tsx
./src/components/foundation/page-wrapper/README.md
./src/components/foundation/page-wrapper/index.ts
./src/components/foundation/project-search-bar/ProjectSearchBar.tsx
./src/components/foundation/project-search-bar/README.md
./src/components/foundation/project-search-bar/index.ts
./src/components/foundation/project-search-bar/types.ts
./src/components/foundation/top-navigation/README.md
./src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx
./src/components/foundation/top-navigation/dark-mode-toggle/README.md
./src/components/foundation/top-navigation/dark-mode-toggle/index.ts
./src/components/foundation/top-navigation/index.ts
./src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx
./src/components/foundation/top-navigation/main-navigation/README.md
./src/components/foundation/top-navigation/main-navigation/index.ts
./src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx
./src/components/foundation/top-navigation/navigation-item/README.md
./src/components/foundation/top-navigation/navigation-item/index.ts
./src/components/foundation/top-navigation/types.ts
./src/components/foundation/top-navigation/wallet-session-badge/README.md
./src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx
./src/components/foundation/top-navigation/wallet-session-badge/index.ts
./src/components/foundation/types.ts
./src/components/foundation/wallet-session-id/README.md
./src/components/foundation/wallet-session-id/WalletSessionId.tsx
./src/components/foundation/wallet-session-id/index.ts
./src/components/foundation/wallet-session-id/types.ts
./src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx
./src/components/iframe/auto-sign-in/README.md
./src/components/iframe/auto-sign-in/index.ts
./src/components/iframe/auto-sign-in/types.ts
./src/components/iframe/check-balance-iframe/CheckBalanceIframe.tsx
./src/components/iframe/check-balance-iframe/useCheckBalance.tsx
./src/components/layout/index.ts
./src/components/layout/navigation/github-session-button/GitHubSessionButton.tsx
./src/components/layout/navigation/github-session-button/README.md
./src/components/layout/navigation/github-session-button/index.ts
./src/components/layout/navigation/github-session-button/types.ts
./src/components/layout/navigation/laconic-icon/LaconicIcon.tsx
./src/components/layout/navigation/laconic-icon/README.md
./src/components/layout/navigation/laconic-icon/index.ts
./src/components/layout/navigation/laconic-icon/types.ts
./src/components/layout/navigation/navigation-actions/NavigationActions.tsx
./src/components/layout/navigation/navigation-actions/README.md
./src/components/layout/navigation/navigation-actions/index.ts
./src/components/layout/navigation/navigation-actions/types.ts
./src/components/layout/navigation/wallet-session-id/README.md
./src/components/layout/navigation/wallet-session-id/WalletSessionId.tsx
./src/components/layout/navigation/wallet-session-id/index.ts
./src/components/layout/navigation/wallet-session-id/types.ts
./src/components/loading/loading-overlay.tsx
./src/components/onboarding/OPTIMIZATION.md
./src/components/onboarding/Onboarding.tsx
./src/components/onboarding/OnboardingButton.tsx
./src/components/onboarding/OnboardingDialog.tsx
./src/components/onboarding/README.md
./src/components/onboarding/common/background-svg.tsx
./src/components/onboarding/common/index.ts
./src/components/onboarding/common/laconic-icon-lettering.tsx
./src/components/onboarding/common/onboarding-container.tsx
./src/components/onboarding/common/step-header.tsx
./src/components/onboarding/common/step-navigation.tsx
./src/components/onboarding/configure-step/configure-step.tsx
./src/components/onboarding/configure-step/index.ts
./src/components/onboarding/connect-step/connect-button.tsx
./src/components/onboarding/connect-step/connect-deploy-first-app.tsx
./src/components/onboarding/connect-step/connect-initial.tsx
./src/components/onboarding/connect-step/connect-step.tsx
./src/components/onboarding/connect-step/index.ts
./src/components/onboarding/connect-step/repository-list.tsx
./src/components/onboarding/connect-step/template-list.tsx
./src/components/onboarding/deploy-step/deploy-step.tsx
./src/components/onboarding/deploy-step/index.ts
./src/components/onboarding/index.ts
./src/components/onboarding/sidebar/index.ts
./src/components/onboarding/sidebar/sidebar-nav.tsx
./src/components/onboarding/store.ts
./src/components/onboarding/types.ts
./src/components/onboarding/useOnboarding.ts
./src/components/projects/project/ProjectCard/FixedProjectCard.tsx
./src/components/projects/project/ProjectCard/ProjectCard.tsx
./src/components/projects/project/ProjectCard/ProjectCardActions.tsx
./src/components/projects/project/ProjectCard/ProjectDeploymentInfo.tsx
./src/components/projects/project/ProjectCard/ProjectStatusDot.tsx
./src/components/projects/project/ProjectCard/index.ts
./src/components/projects/project/ProjectSearchBar/ProjectSearchBar.tsx
./src/components/projects/project/ProjectSearchBar/ProjectSearchBarDialog.tsx
./src/components/projects/project/ProjectSearchBar/ProjectSearchBarEmpty.tsx
./src/components/projects/project/ProjectSearchBar/ProjectSearchBarItem.tsx
./src/components/projects/project/ProjectSearchBar/index.ts
./src/components/projects/project/deployments/DeploymentDetailsCard.tsx
./src/components/projects/project/deployments/FilterForm.tsx
./src/components/projects/project/overview/Activity/AuctionCard.tsx
./src/components/projects/project/overview/OverviewInfo.tsx
./src/components/providers.tsx
./src/context/GQLClientContext.tsx
./src/context/OctokitContext.tsx
./src/context/OctokitProviderWithRouter.tsx
./src/context/WalletContext.tsx
./src/context/WalletContextProvider.tsx
./src/context/index.ts
./src/hooks/useRepoData.tsx
./src/lib/utils.ts
./src/middleware.ts
./src/types/common.ts
./src/types/dashboard.ts
./src/types/deployment.ts
./src/types/hooks/.gitkeep
./src/types/hooks/use-mobile.tsx
./src/types/index.ts
./src/types/project.ts
./src/utils/getInitials.ts
./src/utils/time.ts
./standards/architecture/routes.md
./tailwind.config.ts
./tsconfig.json

View File

@ -0,0 +1,36 @@
// app/actions/github.ts
'use server'
import { auth, currentUser } from '@clerk/nextjs/server'
import { Octokit } from '@octokit/rest'
import type { Organization } from '@octokit/webhooks-types'
export async function getGitHubOrgs() {
const { userId } = await auth()
if (!userId) {
throw new Error('Unauthorized')
}
const user = await currentUser()
const githubAccount = user?.externalAccounts.find(
(account) => account.provider === 'github'
)
const token =
githubAccount?.provider === 'github' ? githubAccount.externalId : null
if (!token) {
throw new Error('GitHub not connected')
}
const octokit = new Octokit({ auth: token })
const { data } = await octokit.rest.orgs.listForAuthenticatedUser()
return data.map((org: Organization) => ({
id: org.id,
name: org.login,
login: org.login,
avatarUrl: org.avatar_url
}))
}

View File

@ -0,0 +1,669 @@
'use client'
import { Separator } from '@radix-ui/react-dropdown-menu'
import { Button } from '@workspace/ui/components/button'
import { Input } from '@workspace/ui/components/input'
import {
Sheet,
SheetContent,
SheetTrigger
} from '@workspace/ui/components/sheet'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from '@workspace/ui/components/tabs'
import {
ChevronDown,
Code,
Copy,
Github,
Globe,
Menu,
Moon,
Search,
Sun,
Terminal,
X
} from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
// Add this component after the imports
function ComingSoonOverlay({ routerAction }: { routerAction: () => void }) {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-background/35 backdrop-blur-sm">
<div className="text-center space-y-4 max-w-md px-4 bg-background/80 p-8 rounded-lg shadow-lg">
<Globe className="h-16 w-16 mx-auto text-primary" />
<h1 className="text-4xl font-bold tracking-tight">Coming Soon</h1>
<p className="text-xl text-muted-foreground">
Our documentation is currently under development. Check back soon for
comprehensive guides and tutorials.
</p>
<div className="pt-4">
<Button size="lg" onClick={() => routerAction()}>
Get Notified When We Launch
</Button>
</div>
</div>
</div>
)
}
export default function DocumentationPage() {
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false)
const [isDarkMode, setIsDarkMode] = useState(false)
const router = useRouter()
const toggleDarkMode = () => {
setIsDarkMode(!isDarkMode)
document.documentElement.classList.toggle('dark')
}
return (
<div className={`min-h-screen flex flex-col ${isDarkMode ? 'dark' : ''}`}>
<ComingSoonOverlay routerAction={() => router.back()} />
{/* Header */}
<header className="sticky top-0 z-40 w-full border-b bg-background">
<div className="container flex h-16 items-center space-x-4 sm:justify-between sm:space-x-0">
<div className="flex gap-6 md:gap-10">
<Link href="/" className="flex items-center space-x-2">
<Globe className="h-6 w-6" />
<span className="inline-block font-bold">Laconic Deploy</span>
</Link>
<nav className="hidden md:flex gap-6">
<Link
href="#"
className="flex items-center text-sm font-medium text-muted-foreground transition-colors hover:text-primary"
>
Documentation
</Link>
<Link
href="#"
className="flex items-center text-sm font-medium text-muted-foreground transition-colors hover:text-primary"
>
API Reference
</Link>
<Link
href="#"
className="flex items-center text-sm font-medium text-muted-foreground transition-colors hover:text-primary"
>
Guides
</Link>
<Link
href="#"
className="flex items-center text-sm font-medium text-muted-foreground transition-colors hover:text-primary"
>
Examples
</Link>
</nav>
</div>
<div className="flex flex-1 items-center space-x-4 sm:justify-end">
<div className="flex-1 sm:grow-0">
<div className="relative hidden md:block">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search documentation..."
className="w-[200px] sm:w-[300px] pl-8"
/>
</div>
</div>
<nav className="flex items-center space-x-1">
<Button variant="ghost" size="icon" onClick={toggleDarkMode}>
{isDarkMode ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
<Link href="#" target="_blank" rel="noreferrer">
<Button variant="ghost" size="icon">
<Github className="h-5 w-5" />
<span className="sr-only">GitHub</span>
</Button>
</Link>
<Sheet open={isMobileNavOpen} onOpenChange={setIsMobileNavOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="pr-0">
<MobileNav onNavClose={() => setIsMobileNavOpen(false)} />
</SheetContent>
</Sheet>
</nav>
</div>
</div>
</header>
<div className="container flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
{/* Sidebar */}
<aside className="fixed top-14 z-30 -ml-2 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 md:sticky md:block">
<div className="h-full py-6 pl-8 pr-6 lg:py-8">
<DocsSidebar />
</div>
</aside>
{/* Main content */}
<main className="relative py-6 lg:gap-10 lg:py-8 xl:grid xl:grid-cols-[1fr_300px]">
<div className="mx-auto w-full min-w-0">
<div className="mb-4 flex items-center space-x-1 text-sm text-muted-foreground">
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
Docs
</div>
<ChevronDown className="h-4 w-4" />
<div className="font-medium text-foreground">Getting Started</div>
</div>
<div className="space-y-2">
<h1 className="scroll-m-20 text-4xl font-bold tracking-tight">
Getting Started with Laconic Deploy
</h1>
<p className="text-lg text-muted-foreground">
Learn how to deploy your applications with Laconic Deploy in
minutes.
</p>
</div>
<Separator className="my-4 md:my-6" />
<div className="prose prose-slate dark:prose-invert max-w-none">
<p>
Laconic Deploy is a modern deployment platform that makes it
easy to deploy your applications to the cloud. With Laconic
Deploy, you can deploy your applications with just a few clicks
or commands.
</p>
<h2>Installation</h2>
<p>
To get started with Laconic Deploy, you need to install the
Laconic CLI. You can install it using npm:
</p>
<div className="relative my-6 overflow-hidden rounded-lg border bg-muted p-2">
<div className="flex items-center bg-slate-950 px-4 py-2 text-xs text-slate-50 dark:bg-slate-800">
<Terminal className="mr-2 h-4 w-4" />
<span>Terminal</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-8 w-8"
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy code</span>
</Button>
</div>
<pre className="p-4 text-sm">
<code>npm install -g laconic-cli</code>
</pre>
</div>
<h2>Authentication</h2>
<p>
After installing the CLI, you need to authenticate with Laconic
Deploy:
</p>
<div className="relative my-6 overflow-hidden rounded-lg border bg-muted p-2">
<div className="flex items-center bg-slate-950 px-4 py-2 text-xs text-slate-50 dark:bg-slate-800">
<Terminal className="mr-2 h-4 w-4" />
<span>Terminal</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-8 w-8"
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy code</span>
</Button>
</div>
<pre className="p-4 text-sm">
<code>laconic login</code>
</pre>
</div>
<h2>Creating Your First Project</h2>
<p>
To create a new project, use the <code>laconic init</code>{' '}
command:
</p>
<div className="relative my-6 overflow-hidden rounded-lg border bg-muted p-2">
<div className="flex items-center bg-slate-950 px-4 py-2 text-xs text-slate-50 dark:bg-slate-800">
<Terminal className="mr-2 h-4 w-4" />
<span>Terminal</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-8 w-8"
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy code</span>
</Button>
</div>
<pre className="p-4 text-sm">
<code>laconic init my-awesome-project</code>
</pre>
</div>
<h2>Deploying Your Application</h2>
<p>
Once your project is set up, you can deploy it with a single
command:
</p>
<div className="relative my-6 overflow-hidden rounded-lg border bg-muted p-2">
<div className="flex items-center bg-slate-950 px-4 py-2 text-xs text-slate-50 dark:bg-slate-800">
<Terminal className="mr-2 h-4 w-4" />
<span>Terminal</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-8 w-8"
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy code</span>
</Button>
</div>
<pre className="p-4 text-sm">
<code>laconic deploy</code>
</pre>
</div>
<h2>Configuration</h2>
<p>
Laconic Deploy uses a <code>laconic.config.js</code> file to
configure your deployments. Here's an example configuration:
</p>
<div className="relative my-6 overflow-hidden rounded-lg border bg-muted p-2">
<div className="flex items-center bg-slate-950 px-4 py-2 text-xs text-slate-50 dark:bg-slate-800">
<Code className="mr-2 h-4 w-4" />
<span>laconic.config.js</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-8 w-8"
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy code</span>
</Button>
</div>
<pre className="p-4 text-sm">
<code>{`module.exports = {
name: 'my-awesome-project',
region: 'us-west-1',
environment: {
NODE_ENV: 'production',
API_URL: 'https://api.example.com'
},
resources: {
compute: {
type: 'container',
size: 'small',
port: 3000
},
database: {
type: 'postgres',
version: '14'
}
}
}`}</code>
</pre>
</div>
<h2>Next Steps</h2>
<p>
Now that you've deployed your first application, you might want
to explore:
</p>
<ul>
<li>Setting up custom domains</li>
<li>Configuring environment variables</li>
<li>Setting up CI/CD pipelines</li>
<li>Monitoring and logging</li>
<li>Scaling your application</li>
</ul>
<div className="not-prose">
<Tabs defaultValue="cli" className="w-full my-6">
<TabsList className="w-full grid grid-cols-3">
<TabsTrigger value="cli">CLI</TabsTrigger>
<TabsTrigger value="api">API</TabsTrigger>
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
</TabsList>
<TabsContent value="cli" className="p-4 border rounded-b-lg">
<p className="text-sm">
The Laconic CLI provides a powerful command-line interface
for managing your deployments. See the{' '}
<Link href="#" className="text-primary hover:underline">
CLI Reference
</Link>{' '}
for more information.
</p>
</TabsContent>
<TabsContent value="api" className="p-4 border rounded-b-lg">
<p className="text-sm">
The Laconic API allows you to programmatically manage your
deployments. See the{' '}
<Link href="#" className="text-primary hover:underline">
API Reference
</Link>{' '}
for more information.
</p>
</TabsContent>
<TabsContent
value="dashboard"
className="p-4 border rounded-b-lg"
>
<p className="text-sm">
The Laconic Dashboard provides a web interface for
managing your deployments. Visit the{' '}
<Link href="#" className="text-primary hover:underline">
Dashboard
</Link>{' '}
to get started.
</p>
</TabsContent>
</Tabs>
</div>
</div>
<div className="flex justify-between mt-8 border-t pt-4">
<Button variant="outline" size="sm">
<ChevronDown className="mr-2 h-4 w-4 rotate-90" />
Introduction
</Button>
<Button variant="outline" size="sm">
Custom Domains
<ChevronDown className="ml-2 h-4 w-4 -rotate-90" />
</Button>
</div>
</div>
{/* Table of contents - desktop only */}
<div className="hidden text-sm xl:block">
<div className="sticky top-16 -mt-10 pt-10">
<div className="pb-8">
<div className="sticky top-16 -mt-10 pt-10">
<h4 className="mb-1 font-medium">On This Page</h4>
<ul className="m-0 list-none">
<li className="mt-2 pt-2">
<a
href="#installation"
className="text-muted-foreground hover:text-foreground"
>
Installation
</a>
</li>
<li className="mt-2 pt-2">
<a
href="#authentication"
className="text-muted-foreground hover:text-foreground"
>
Authentication
</a>
</li>
<li className="mt-2 pt-2">
<a
href="#creating-your-first-project"
className="text-muted-foreground hover:text-foreground"
>
Creating Your First Project
</a>
</li>
<li className="mt-2 pt-2">
<a
href="#deploying-your-application"
className="text-muted-foreground hover:text-foreground"
>
Deploying Your Application
</a>
</li>
<li className="mt-2 pt-2">
<a
href="#configuration"
className="text-muted-foreground hover:text-foreground"
>
Configuration
</a>
</li>
<li className="mt-2 pt-2">
<a
href="#next-steps"
className="text-muted-foreground hover:text-foreground"
>
Next Steps
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</main>
</div>
<footer className="border-t py-6 md:py-0">
<div className="container flex flex-col items-center justify-between gap-4 md:h-24 md:flex-row">
<p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
© {new Date().getFullYear()} Laconic Deploy. All rights reserved.
</p>
<div className="flex items-center gap-4">
<Link
href="#"
className="text-sm text-muted-foreground hover:text-foreground"
>
Terms
</Link>
<Link
href="#"
className="text-sm text-muted-foreground hover:text-foreground"
>
Privacy
</Link>
<Link
href="#"
className="text-sm text-muted-foreground hover:text-foreground"
>
Contact
</Link>
</div>
</div>
</footer>
</div>
)
}
function MobileNav({ onNavClose }: { onNavClose: () => void }) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center h-16 px-4 border-b">
<Link href="/" className="flex items-center gap-2" onClick={onNavClose}>
<Globe className="h-6 w-6" />
<span className="font-bold">Laconic Deploy</span>
</Link>
<Button
variant="ghost"
size="icon"
className="ml-auto"
onClick={onNavClose}
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</Button>
</div>
<div className="flex-1 overflow-auto py-4">
<div className="px-4 mb-4">
<Input
type="search"
placeholder="Search documentation..."
className="w-full"
/>
</div>
<DocsSidebar mobile onNavClose={onNavClose} />
</div>
</div>
)
}
function DocsSidebar({
mobile = false,
onNavClose
}: { mobile?: boolean; onNavClose?: () => void }) {
const handleLinkClick = () => {
if (mobile && onNavClose) {
onNavClose()
}
}
return (
<div className="w-full">
<div className="pb-4">
<h4 className="mb-1 rounded-md px-2 py-1 text-sm font-medium">
Getting Started
</h4>
<div className="grid grid-flow-row auto-rows-max text-sm">
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Introduction
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-foreground font-medium"
onClick={handleLinkClick}
>
Getting Started
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Installation
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
CLI Setup
</Link>
</div>
</div>
<div className="pb-4">
<h4 className="mb-1 rounded-md px-2 py-1 text-sm font-medium">
Core Concepts
</h4>
<div className="grid grid-flow-row auto-rows-max text-sm">
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Projects
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Environments
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Deployments
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Resources
</Link>
</div>
</div>
<div className="pb-4">
<h4 className="mb-1 rounded-md px-2 py-1 text-sm font-medium">
Guides
</h4>
<div className="grid grid-flow-row auto-rows-max text-sm">
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Custom Domains
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Environment Variables
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
CI/CD Integration
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Monitoring & Logging
</Link>
</div>
</div>
<div className="pb-4">
<h4 className="mb-1 rounded-md px-2 py-1 text-sm font-medium">
API Reference
</h4>
<div className="grid grid-flow-row auto-rows-max text-sm">
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Authentication
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Projects API
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Deployments API
</Link>
<Link
href="#"
className="group flex w-full items-center rounded-md border border-transparent px-2 py-1 hover:underline text-muted-foreground hover:text-foreground"
onClick={handleLinkClick}
>
Resources API
</Link>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,12 @@
// Documentation page for the Deploy platform using Pagewrapper component
import PageWrapper from '@/components/foundation/page-wrapper/PageWrapper'
import DocumentationPlaceholder from './DocumentationPlaceholder'
export default function DocumentationPage() {
return (
<PageWrapper header={{ title: 'Documentation' }}>
<DocumentationPlaceholder />
</PageWrapper>
)
}

View File

@ -0,0 +1,6 @@
'use client'
import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay'
export default function Loading() {
return <LoadingOverlay />
}

View File

@ -0,0 +1,191 @@
import { PageWrapper } from '@/components/foundation'
import { auth, currentUser } from '@clerk/nextjs/server'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { Octokit } from '@octokit/rest'
import { Shapes } from 'lucide-react'
import { Button } from '@workspace/ui/components/button'
/**
* Dashboard page
* @returns {React.ReactNode} The rendered component
*/
export default async function Page() {
const authenticated = await auth()
const userId = authenticated.userId
if (!userId) {
return notFound()
}
try {
const user = await currentUser()
const githubAccount = user?.externalAccounts.find(
(account) => account.provider === 'oauth_github'
)
if (!githubAccount) {
return (
<PageWrapper
header={{
title: 'Dashboard',
actions: [{ label: 'Create Project', href: '/projects/create' }]
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 border border-gray-800 rounded-lg min-h-[600px] flex flex-col items-center justify-center p-6">
<div className="mb-6">
<div className="flex flex-col items-center">
<Shapes size={64} className="stroke-current" />
</div>
</div>
<div className="text-2xl font-bold mb-2">
GitHub Account Not Connected
</div>
<div className="text-muted-foreground text-center max-w-md mb-6">
You need to connect your GitHub account to use the dashboard features.
Please visit your user profile in Clerk to connect GitHub.
</div>
<Button
className="bg-white text-black hover:bg-gray-200 flex items-center"
asChild
>
<Link href="https://accounts.clerk.dev/user" target="_blank">
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
Connect to GitHub
</Link>
</Button>
</div>
</PageWrapper>
)
}
// NOTE: We're keeping the token approach for now, but aware it's not working
const authToken = githubAccount.accessToken;
// Try using GitHub token
let octokit;
try {
octokit = new Octokit({
auth: authToken || process.env.GITHUB_TOKEN
});
// Test with a simple request
// Try listing repositories
const repoResponse = await octokit.repos.listForAuthenticatedUser();
return (
<PageWrapper
header={{
title: 'Dashboard',
actions: [{ label: 'Create Project', href: '/projects/create' }]
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{repoResponse.data.length > 0 ? (
repoResponse.data.map((repo) => (
<div
key={repo.id}
className="border border-gray-800 rounded-lg p-6 flex flex-col"
>
<h3 className="text-lg font-medium mb-2">{repo.name}</h3>
<p className="text-muted-foreground text-sm mb-4 flex-grow">
{repo.description || 'No description provided'}
</p>
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm text-muted-foreground">
{repo.default_branch}
</span>
</div>
<Link
href={repo.html_url}
target="_blank"
className="text-sm text-blue-400 hover:underline"
>
View on GitHub
</Link>
</div>
</div>
))
) : (
<div className="md:col-span-3 flex justify-center items-center min-h-[400px]">
<p className="text-muted-foreground">No repositories found</p>
</div>
)}
</div>
</div>
</PageWrapper>
);
} catch (authError) {
console.error("GitHub API error:", authError);
return (
<PageWrapper
header={{
title: 'Dashboard',
actions: [{ label: 'Create Project', href: '/projects/create' }]
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 border border-gray-800 rounded-lg min-h-[600px] flex flex-col items-center justify-center p-6">
<div className="mb-6">
<div className="flex flex-col items-center">
<Shapes size={64} className="stroke-current" />
</div>
</div>
<div className="text-xl font-bold mb-2">
Failed to access GitHub API
</div>
<div className="text-red-500 mb-4">
{authError.message}
</div>
<div className="text-muted-foreground text-center max-w-md mb-6">
<p>This issue may be related to how Clerk is managing the GitHub token.</p>
<p className="mt-2">Try reconnecting your GitHub account with the correct permissions.</p>
</div>
<Button
className="bg-white text-black hover:bg-gray-200 flex items-center"
asChild
>
<Link href="https://accounts.clerk.dev/user" target="_blank">
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
Connect to GitHub
</Link>
</Button>
</div>
</PageWrapper>
);
}
} catch (error) {
console.error("GitHub authentication error:", error);
return (
<PageWrapper
header={{
title: 'Dashboard',
actions: [{ label: 'Create Project', href: '/projects/create' }]
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 flex flex-col items-center justify-center min-h-[600px]">
<div className="text-2xl font-bold mb-4">
Failed to authenticate with GitHub
</div>
<div className="text-red-500">
{error.message}
</div>
</div>
</PageWrapper>
);
}
}

View File

@ -0,0 +1,19 @@
'use client'
import NavigationWrapper from '@/components/foundation/navigation-wrapper/NavigationWrapper'
import { TopNavigation } from '@/components/foundation/top-navigation'
interface LayoutProps {
children: React.ReactNode
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<NavigationWrapper>
<TopNavigation />
{children}
</NavigationWrapper>
)
}
export default Layout

View File

@ -0,0 +1,227 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { PageWrapper } from '@/components/foundation'
import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Input } from '@workspace/ui/components/input'
import { Label } from '@workspace/ui/components/label'
import { Button } from '@workspace/ui/components/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
import { toast } from 'sonner'
import { Stepper } from '@/components/core/stepper/Stepper'
import { useRepoData } from '@/hooks/useRepoData'
export default function ConfigureDeploymentPage() {
const router = useRouter()
const params = useParams()
const providerParam = params?.provider ? String(params.provider) : 'github'
// Use the existing useRepoData hook to fetch all repos (empty string for ID means all repos)
const { repoData: repositories, isLoading } = useRepoData('')
const [selectedRepo, setSelectedRepo] = useState<string>('')
const [selectedBranch, setSelectedBranch] = useState<string>('main')
const [projectName, setProjectName] = useState<string>('')
const [branches, setBranches] = useState<string[]>(['main'])
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([
{ key: '', value: '' }
])
// Define stepper values for the existing Stepper component
const stepperValues = [
{ step: 1, label: 'Select Repository', route: '/projects/github/ps/cr/tm/cf' },
{ step: 2, label: 'Configure', route: '/projects/github/ps/cr/cf' },
{ step: 3, label: 'Deploy', route: '/projects/github/ps/cr/dp' },
{ step: 4, label: 'Success', route: '/projects/github/ps/cr/sc' }
]
// When a repository is selected, update project name and branch
useEffect(() => {
if (!selectedRepo || !repositories) return
const repo = repositories.find(r => r.full_name === selectedRepo)
if (repo) {
setProjectName(repo.name)
setSelectedBranch(repo.default_branch)
// For simplicity, just use the default branch and some common branch names
// In a real implementation, you would fetch branches for the selected repo
setBranches([repo.default_branch, 'develop', 'feature/new-ui'])
}
}, [selectedRepo, repositories])
const handleRepoChange = (repo: string) => {
setSelectedRepo(repo)
}
const handleBranchChange = (branch: string) => {
setSelectedBranch(branch)
}
const handleProjectNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setProjectName(e.target.value)
}
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
// Add a new empty row if the last row has both key and value filled
if (
index === newEnvVars.length - 1 &&
newEnvVars[index].key !== '' &&
newEnvVars[index].value !== ''
) {
newEnvVars.push({ key: '', value: '' })
}
setEnvVars(newEnvVars)
}
const handleSubmit = () => {
if (!selectedRepo || !selectedBranch || !projectName) {
toast.error('Please fill in all required fields')
return
}
// Filter out empty env vars
const filteredEnvVars = envVars.filter(
envVar => envVar.key.trim() !== '' && envVar.value.trim() !== ''
)
// Convert env vars array to object
const environmentVariables = filteredEnvVars.reduce(
(acc, { key, value }) => ({ ...acc, [key]: value }),
{}
)
// Find the selected repository to get its URL
const repo = repositories?.find(r => r.full_name === selectedRepo)
// Store the configuration in session storage to be used in the next step
sessionStorage.setItem(
'deploymentConfig',
JSON.stringify({
repositoryUrl: selectedRepo,
repositoryHtmlUrl: repo?.html_url || `https://github.com/${selectedRepo}`,
branch: selectedBranch,
projectName,
environmentVariables
})
)
// Navigate to the deployment page
router.push(`/projects/${providerParam}/ps/cr/dp`)
}
if (isLoading) {
return <LoadingOverlay isLoading={true} />
}
return (
<PageWrapper
header={{
title: 'Configure Deployment',
description: 'Set up your project deployment configuration'
}}
>
<div className="max-w-3xl mx-auto">
{/* Using the existing Stepper component with the correct props */}
<Stepper activeStep={2} stepperValues={stepperValues} />
<Card className="mt-6">
<CardHeader>
<CardTitle>Project Configuration</CardTitle>
<CardDescription>
Configure your project settings for deployment
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="repo">GitHub Repository</Label>
<Select
value={selectedRepo}
onValueChange={handleRepoChange}
>
<SelectTrigger id="repo">
<SelectValue placeholder="Select a repository" />
</SelectTrigger>
<SelectContent>
{repositories && repositories.map(repo => (
<SelectItem key={repo.id} value={repo.full_name}>
{repo.full_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="branch">Branch</Label>
<Select
value={selectedBranch}
onValueChange={handleBranchChange}
disabled={!selectedRepo}
>
<SelectTrigger id="branch">
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
{branches.map(branch => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="projectName">Project Name</Label>
<Input
id="projectName"
value={projectName}
onChange={handleProjectNameChange}
placeholder="Enter a name for your project"
/>
</div>
<div className="space-y-2">
<Label>Environment Variables</Label>
<div className="space-y-2">
{envVars.map((envVar, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="KEY"
value={envVar.key}
onChange={e => handleEnvVarChange(index, 'key', e.target.value)}
className="flex-1"
/>
<Input
placeholder="VALUE"
value={envVar.value}
onChange={e => handleEnvVarChange(index, 'value', e.target.value)}
className="flex-1"
/>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Environment variables will be securely stored and available during build and runtime.
</p>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" onClick={() => router.back()}>
Back
</Button>
<Button onClick={handleSubmit}>
Continue to Deployment
</Button>
</CardFooter>
</Card>
</div>
</PageWrapper>
)
}

View File

@ -0,0 +1,270 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { PageWrapper } from '@/components/foundation'
import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay'
import { Stepper } from '@/components/core/stepper/Stepper'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Button } from '@workspace/ui/components/button'
import { toast } from 'sonner'
import { Progress } from '@workspace/ui/components/progress'
import { Loader2, CheckCircle, AlertCircle, GitBranch } from 'lucide-react'
import { StopWatch } from '@/components/core/stop-watch'
interface DeploymentConfig {
repositoryUrl: string;
repositoryHtmlUrl: string;
branch: string;
projectName: string;
environmentVariables: Record<string, string>;
}
export default function DeployPage() {
const router = useRouter()
const params = useParams()
const providerParam = params?.provider ? String(params.provider) : 'github'
const [deploymentConfig, setDeploymentConfig] = useState<DeploymentConfig | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isDeploying, setIsDeploying] = useState(false)
const [deploymentStatus, setDeploymentStatus] = useState<'idle' | 'pending' | 'building' | 'ready' | 'error'>('idle')
const [deploymentProgress, setDeploymentProgress] = useState<number>(0)
const [, setElapsedTime] = useState<number>(0)
const [deploymentId, setDeploymentId] = useState<string>('')
// Define stepper values for the existing Stepper component
const stepperValues = [
{ step: 1, label: 'Select Repository', route: '/projects/github/ps/cr/tm/cf' },
{ step: 2, label: 'Configure', route: '/projects/github/ps/cr/cf' },
{ step: 3, label: 'Deploy', route: '/projects/github/ps/cr/dp' },
{ step: 4, label: 'Success', route: '/projects/github/ps/cr/sc' }
]
// Load deployment config from session storage
useEffect(() => {
const storedConfig = sessionStorage.getItem('deploymentConfig')
if (storedConfig) {
setDeploymentConfig(JSON.parse(storedConfig))
} else {
toast.error('Deployment configuration not found')
router.push(`/projects/${providerParam}/ps/cr/cf`)
}
setIsLoading(false)
}, [router, providerParam])
// Handle elapsed time updates from StopWatch component
const handleTimeUpdate = (time: number) => {
setElapsedTime(time)
}
// Simulate deployment process (would connect to your backend in a real implementation)
const startDeployment = () => {
if (!deploymentConfig) {
toast.error('Deployment configuration not found')
return
}
setIsDeploying(true)
setDeploymentStatus('pending')
setDeploymentProgress(10)
// Simulate deployment steps with timeouts
setTimeout(() => {
setDeploymentStatus('building')
setDeploymentProgress(40)
setTimeout(() => {
// 80% chance of success, 20% chance of failure (for demo purposes)
const success = Math.random() < 0.8
if (success) {
setDeploymentStatus('ready')
setDeploymentProgress(100)
// Generate a random ID for the deployment
const id = Math.random().toString(36).substring(2, 10)
setDeploymentId(id)
// Store deployment details in session storage
const deploymentDetails = {
id,
url: `https://${deploymentConfig.projectName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.laconic.deploy`,
projectId: 'project_' + Math.random().toString(36).substring(2, 10),
projectName: deploymentConfig.projectName,
status: 'ready',
createdAt: new Date().toISOString(),
repository: {
name: deploymentConfig.repositoryUrl.split('/')[1],
url: deploymentConfig.repositoryHtmlUrl || `https://github.com/${deploymentConfig.repositoryUrl}`,
branch: deploymentConfig.branch
}
};
sessionStorage.setItem('deploymentResult', JSON.stringify(deploymentDetails))
// Navigate to success page after a short delay
setTimeout(() => {
router.push(`/projects/${providerParam}/ps/cr/sc/${id}`)
}, 2000)
} else {
setDeploymentStatus('error')
setDeploymentProgress(100)
}
setIsDeploying(false)
}, 5000) // 5 seconds for building
}, 3000) // 3 seconds for pending
}
const getStatusIcon = () => {
switch (deploymentStatus) {
case 'pending':
return <Loader2 className="h-6 w-6 animate-spin text-blue-500" />
case 'building':
return <Loader2 className="h-6 w-6 animate-spin text-blue-500" />
case 'ready':
return <CheckCircle className="h-6 w-6 text-green-500" />
case 'error':
return <AlertCircle className="h-6 w-6 text-red-500" />
default:
return null
}
}
const getStatusText = () => {
switch (deploymentStatus) {
case 'pending':
return 'Preparing deployment...'
case 'building':
return 'Building your project...'
case 'ready':
return 'Deployment successful!'
case 'error':
return 'Deployment failed'
default:
return 'Ready to deploy'
}
}
if (isLoading) {
return <LoadingOverlay isLoading={true} />
}
return (
<PageWrapper
header={{
title: 'Deploy Project',
description: 'Deploy your project to Laconic'
}}
>
<div className="max-w-3xl mx-auto">
{/* Using the existing Stepper component with the correct props */}
<Stepper activeStep={3} stepperValues={stepperValues} />
<Card className="mt-6">
<CardHeader>
<CardTitle>Deployment</CardTitle>
<CardDescription>
Deploy your project to Laconic's decentralized hosting
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{deploymentConfig && (
<div className="space-y-4">
<div>
<p className="text-sm font-medium">Repository</p>
<div className="flex items-center">
<GitBranch className="h-4 w-4 mr-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{deploymentConfig.repositoryUrl} ({deploymentConfig.branch})
</p>
</div>
</div>
<div>
<p className="text-sm font-medium">Project Name</p>
<p className="text-sm text-muted-foreground">
{deploymentConfig.projectName}
</p>
</div>
{Object.keys(deploymentConfig.environmentVariables || {}).length > 0 && (
<div>
<p className="text-sm font-medium">Environment Variables</p>
<p className="text-sm text-muted-foreground">
{Object.keys(deploymentConfig.environmentVariables).length} environment variables configured
</p>
</div>
)}
</div>
)}
{deploymentStatus === 'idle' ? (
<div className="flex flex-col items-center justify-center py-6">
<p className="text-center mb-4">
Ready to deploy your project? Click the button below to start the deployment process.
</p>
<Button
onClick={startDeployment}
disabled={isDeploying}
className="w-full sm:w-auto"
>
{isDeploying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deploying...
</>
) : (
'Start Deployment'
)}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{getStatusIcon()}
<p className="text-sm font-medium">{getStatusText()}</p>
</div>
{/* Using your existing StopWatch component */}
<StopWatch
start={deploymentStatus !== 'ready' && deploymentStatus !== 'error'}
onTimeUpdate={handleTimeUpdate}
/>
</div>
<Progress value={deploymentProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-center">
{deploymentStatus === 'pending' && 'Setting up the deployment environment...'}
{deploymentStatus === 'building' && 'Building your application...'}
{deploymentStatus === 'ready' && 'Deployment completed successfully!'}
{deploymentStatus === 'error' && 'There was an error deploying your application. Please try again.'}
</p>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between">
<Button
variant="outline"
onClick={() => router.back()}
disabled={deploymentStatus === 'pending' || deploymentStatus === 'building'}
>
Back
</Button>
{deploymentStatus === 'ready' && (
<Button
onClick={() => router.push(`/projects/${providerParam}/ps/cr/sc/${deploymentId}`)}
>
View Deployment
</Button>
)}
{deploymentStatus === 'error' && (
<Button onClick={startDeployment}>
Retry Deployment
</Button>
)}
</CardFooter>
</Card>
</div>
</PageWrapper>
)
}

View File

@ -0,0 +1,258 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { PageWrapper } from '@/components/foundation'
import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay'
import { Stepper } from '@/components/core/stepper/Stepper'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Button } from '@workspace/ui/components/button'
import { toast } from 'sonner'
import { CheckCircle, Copy, ExternalLink, Clock } from 'lucide-react'
import Link from 'next/link'
import { relativeTimeMs } from '@/utils/time'
import { getInitials } from '@/utils/getInitials'
import { Avatar, AvatarFallback } from '@workspace/ui/components/avatar'
interface DeploymentDetails {
id: string;
url: string;
projectId: string;
projectName: string;
status: string;
createdAt: string;
repository: {
name: string;
url: string;
branch: string;
};
}
export default function SuccessPage({ params }: { params: { id: string } }) {
const router = useRouter()
const paramsObj = useParams()
const providerParam = paramsObj?.provider ? String(paramsObj.provider) : 'github'
const [isLoading, setIsLoading] = useState(true)
const [deployment, setDeployment] = useState<DeploymentDetails | null>(null)
const deploymentId = params.id
// Define stepper values for the existing Stepper component
const stepperValues = [
{ step: 1, label: 'Select Repository', route: '/projects/github/ps/cr/tm/cf' },
{ step: 2, label: 'Configure', route: '/projects/github/ps/cr/cf' },
{ step: 3, label: 'Deploy', route: '/projects/github/ps/cr/dp' },
{ step: 4, label: 'Success', route: '/projects/github/ps/cr/sc' }
]
// Get deployment details from session storage
useEffect(() => {
// For now, we'll get the deployment details from session storage
// In a real app, you'd fetch this from your API
const storedDeployment = sessionStorage.getItem('deploymentResult')
if (storedDeployment) {
setDeployment(JSON.parse(storedDeployment))
} else {
// If not found in session storage, simulate it (for demo purposes)
// In a real app, you'd fetch from the API using the ID
simulateDeploymentDetails()
}
setIsLoading(false)
}, [deploymentId])
// Simulate deployment details if needed (for demo purposes)
const simulateDeploymentDetails = () => {
const mockDeployment: DeploymentDetails = {
id: deploymentId,
url: `https://project-${deploymentId}.laconic.deploy`,
projectId: 'project_' + Math.random().toString(36).substring(2, 10),
projectName: 'Demo Project',
status: 'ready',
createdAt: new Date().toISOString(),
repository: {
name: 'demo-repo',
url: 'https://github.com/yourusername/demo-repo',
branch: 'main'
}
}
setDeployment(mockDeployment)
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
toast.success('Copied to clipboard')
}
if (isLoading) {
return <LoadingOverlay isLoading={true} />
}
if (!deployment) {
return (
<PageWrapper
header={{
title: 'Deployment Not Found',
description: 'The deployment you are looking for does not exist'
}}
>
<div className="max-w-3xl mx-auto">
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center justify-center py-12">
<p className="text-center mb-6">
We couldn't find the deployment you're looking for. It may have been deleted or expired.
</p>
<Button asChild>
<Link href="/projects">View All Projects</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</PageWrapper>
)
}
// Calculate relative time for the deployment
const deploymentTime = new Date(deployment.createdAt).getTime()
const deployedBy = 'You' // In a real app, you'd get this from the deployment data
return (
<PageWrapper
header={{
title: 'Deployment Success',
description: 'Your project has been successfully deployed',
actions: [
{
label: 'View App',
href: deployment.url,
icon: 'external-link',
external: true
}
]
}}
>
<div className="max-w-3xl mx-auto">
{/* Using the existing Stepper component with the correct props */}
<Stepper activeStep={4} stepperValues={stepperValues} />
<Card className="mt-6">
<CardHeader className="pb-4">
<div className="flex items-center space-x-2">
<CheckCircle className="h-6 w-6 text-green-500" />
<CardTitle>Deployment Successful</CardTitle>
</div>
<CardDescription>
Your project has been successfully deployed to Laconic's decentralized hosting
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="p-4 border rounded-lg bg-muted/50 relative">
<h3 className="font-medium mb-2">Deployment URL</h3>
<div className="flex items-center">
<code className="text-sm bg-background p-2 rounded flex-1 overflow-x-auto">
{deployment.url}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(deployment.url)}
className="ml-2"
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="ml-2"
asChild
>
<a href={deployment.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="font-medium mb-2">Project Details</h3>
<ul className="space-y-2 text-sm">
<li>
<span className="text-muted-foreground">Project Name:</span>{' '}
{deployment.projectName}
</li>
<li>
<span className="text-muted-foreground">Repository:</span>{' '}
{deployment.repository.name}
</li>
<li>
<span className="text-muted-foreground">Branch:</span>{' '}
{deployment.repository.branch}
</li>
<li>
<span className="text-muted-foreground">Deployment ID:</span>{' '}
<code className="text-xs bg-muted p-1 rounded">{deployment.id}</code>
</li>
</ul>
</div>
<div>
<h3 className="font-medium mb-2">Deployment Information</h3>
<ul className="space-y-2 text-sm">
<li>
<span className="text-muted-foreground">Status:</span>{' '}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">
{deployment.status.toUpperCase()}
</span>
</li>
<li>
<div className="flex items-center">
<Clock className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground">Deployed at:</span>{' '}
<span className="ml-1">{relativeTimeMs(deploymentTime)}</span>
</div>
</li>
<li>
<div className="flex items-center">
<span className="text-muted-foreground">Deployed by:</span>{' '}
<span className="flex items-center ml-1">
<Avatar className="h-5 w-5 mr-1">
<AvatarFallback>{getInitials(deployedBy)}</AvatarFallback>
</Avatar>
{deployedBy}
</span>
</div>
</li>
</ul>
</div>
</div>
<div className="p-4 border rounded-lg bg-background relative">
<h3 className="font-medium mb-2">What's Next?</h3>
<ul className="space-y-2 text-sm">
<li> Configure custom domains for your deployment</li>
<li> Set up automatic deployments for new commits</li>
<li> Add collaborators to your project</li>
<li> Monitor deployment performance and analytics</li>
</ul>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
variant="outline"
onClick={() => router.push('/projects')}
>
View All Projects
</Button>
<Button
onClick={() => router.push(`/projects/${providerParam}/ps/${deployment.projectId}`)}
>
Go to Project Dashboard
</Button>
</CardFooter>
</Card>
</div>
</PageWrapper>
)

View File

@ -0,0 +1,12 @@
const Page = () => {
return (
<div>
<h1>
Hello from
(web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/(create)/cr/(template)/tm/(configure)/cf
</h1>
</div>
)
}
export default Page

View File

@ -0,0 +1,16 @@
import { PageWrapper } from '@/components/foundation'
const Page = () => {
return (
<PageWrapper header={{ title: 'Deploy Project' }}>
<div>
<h1>
Hello from
(web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/(create)/cr/(template)/tm/(deploy)/dp
</h1>
</div>
</PageWrapper>
)
}
export default Page

View File

@ -0,0 +1,185 @@
'use client';
import { useParams } from 'next/navigation';
import { PageWrapper } from '@/components/foundation';
import { DeploymentDetailsCard } from '@/components/projects/project/deployments/DeploymentDetailsCard';
import { FilterForm } from '@/components/projects/project/deployments/FilterForm';
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { IconButton } from '@workspace/ui/components/button';
import { Rocket } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useRepoData } from '@/hooks/useRepoData';
import type { Deployment, Domain } from '@/types';
export default function DeploymentsPage() {
const router = useRouter();
const params = useParams();
// Safely unwrap params
const id = params?.id ? String(params.id) : '';
const provider = params?.provider ? String(params.provider) : '';
// Use the hook to get repo data
const { repoData } = useRepoData(id);
// Mock deployments data - in a real app, you would fetch this from an API
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [filteredDeployments, setFilteredDeployments] = useState<Deployment[]>([]);
const [prodBranchDomains, setProdBranchDomains] = useState<Domain[]>([]);
// Create a default deployment
const defaultDeployment: Deployment = {
id: 'default',
branch: 'main',
status: 'COMPLETED',
isCurrent: true,
createdAt: Date.now() - 24 * 60 * 60 * 1000, // 1 day ago
applicationDeploymentRecordData: {
url: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : 'https://example.com'
},
createdBy: {
name: repoData?.owner?.login || 'username'
}
};
const secondDeployment: Deployment = {
id: 'previous',
branch: 'feature/new-ui',
status: 'COMPLETED',
isCurrent: false,
createdAt: Date.now() - 3 * 24 * 60 * 60 * 1000, // 3 days ago
applicationDeploymentRecordData: {
url: repoData ? `https://dev.${repoData.name.toLowerCase()}.example.com` : 'https://dev.example.com'
},
createdBy: {
name: repoData?.owner?.login || 'username'
}
};
// Initialize with mock data
useEffect(() => {
const mockDeployments = [defaultDeployment, secondDeployment];
setDeployments(mockDeployments);
setFilteredDeployments(mockDeployments);
// Mock domains
const mockDomains: Domain[] = [
{
id: '1',
name: repoData ? `${repoData.name.toLowerCase()}.example.com` : 'example.com',
status: 'ACTIVE',
isCustom: false
}
];
setProdBranchDomains(mockDomains);
}, [repoData]);
// Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => {
const basePath = `/projects/${provider}/ps/${id}`;
switch (value) {
case 'overview':
router.push(basePath);
break;
case 'deployment':
router.push(`${basePath}/dep`);
break;
case 'settings':
router.push(`${basePath}/set`);
break;
case 'git':
router.push(`${basePath}/int`);
break;
case 'env-vars':
router.push(`${basePath}/set/env`);
break;
}
};
// Reset filters handler
const handleResetFilters = () => {
setFilteredDeployments(deployments);
};
const project = {
id: id,
prodBranch: 'main',
name: repoData?.name || 'Project'
};
const currentDeployment = deployments.find(deployment => deployment.isCurrent) || defaultDeployment;
return (
<PageWrapper
header={{
title: repoData ? `${repoData.name}` : 'Project Deployments',
actions: [
{
label: 'Open repo',
href: repoData?.html_url || '#',
icon: 'external-link',
external: true
},
{
label: 'View app',
href: currentDeployment.applicationDeploymentRecordData.url,
icon: 'external-link',
external: true
}
]
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full">
{/* Tabs navigation */}
<Tabs defaultValue="deployment" className="w-full" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="deployment">Deployment</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="git">Git</TabsTrigger>
<TabsTrigger value="env-vars">Environment Variables</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-6">
<FilterForm />
<div className="h-full mt-4">
{filteredDeployments.length > 0 ? (
filteredDeployments.map((deployment) => (
<DeploymentDetailsCard
key={deployment.id}
deployment={deployment}
currentDeployment={currentDeployment}
project={project}
prodBranchDomains={prodBranchDomains}
/>
))
) : (
<div className="h-96 bg-base-bg-alternate dark:bg-overlay3 rounded-xl flex flex-col items-center justify-center gap-5 text-center">
<div className="space-y-1">
<p className="font-medium tracking-[-0.011em] text-elements-high-em dark:text-foreground">
No deployments found
</p>
<p className="text-sm tracking-[-0.006em] text-elements-mid-em dark:text-foreground-secondary">
Please change your search query or filters.
</p>
</div>
<IconButton
variant="outline"
size="sm"
leftIcon={<Rocket className="w-4 h-4" />}
onClick={handleResetFilters}
>
RESET FILTERS
</IconButton>
</div>
)}
</div>
</div>
</div>
</PageWrapper>
);
}

View File

@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay";
interface SwitchProps {
id: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
function Switch({ id, checked, onChange, disabled = false }: SwitchProps) {
return (
<label
htmlFor={id}
className={`relative inline-flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="checkbox"
id={id}
className="sr-only"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
<div
className={`relative w-11 h-6 bg-gray-800 rounded-full transition-colors
${checked ? 'bg-blue-600' : 'bg-gray-700'}`}
>
<div
className={`absolute w-4 h-4 bg-white rounded-full transition-transform transform
${checked ? 'translate-x-6' : 'translate-x-1'} top-1`}
></div>
</div>
</label>
);
}
export default function GitPage() {
const params = useParams();
const { provider, id } = params;
const [pullRequestComments, setPullRequestComments] = useState(true);
const [commitComments, setCommitComments] = useState(false);
const [productionBranch, setProductionBranch] = useState("main");
const [webhookUrl, setWebhookUrl] = useState("");
const [isSavingBranch, setIsSavingBranch] = useState(false);
const [isSavingWebhook, setIsSavingWebhook] = useState(false);
const handleSaveBranch = async () => {
try {
setIsSavingBranch(true);
// Save production branch
console.log("Saving production branch:", productionBranch);
// Implement API call to save production branch
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
// Show success notification
} catch (error) {
console.error("Failed to save production branch:", error);
// Show error notification
} finally {
setIsSavingBranch(false);
}
};
const handleSaveWebhook = async () => {
try {
setIsSavingWebhook(true);
// Save webhook URL
console.log("Saving webhook URL:", webhookUrl);
// Implement API call to save webhook URL
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
// Show success notification
} catch (error) {
console.error("Failed to save webhook URL:", error);
// Show error notification
} finally {
setIsSavingWebhook(false);
}
};
return (
<>
{(isSavingBranch || isSavingWebhook) && <LoadingOverlay />}
<div className="space-y-8 w-full">
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Git repository</h2>
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center space-x-3">
<Switch
id="pull-request-comments"
checked={pullRequestComments}
onChange={setPullRequestComments}
/>
<label htmlFor="pull-request-comments" className="text-sm font-medium">
Pull request comments
</label>
</div>
<p className="text-sm text-gray-400 mt-1 ml-14">
Laconic will comment on pull requests opened against this project.
</p>
</div>
</div>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center space-x-3">
<Switch
id="commit-comments"
checked={commitComments}
onChange={setCommitComments}
/>
<label htmlFor="commit-comments" className="text-sm font-medium">
Commit comments
</label>
</div>
<p className="text-sm text-gray-400 mt-1 ml-14">
Laconic will comment on commits deployed to production.
</p>
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Production branch</h2>
<p className="text-sm text-gray-400 mb-4">
By default, each commit pushed to the main branch initiates a production deployment. You can opt for a
different branch for deployment in the settings.
</p>
<div className="space-y-4">
<div>
<label htmlFor="branch-name" className="block text-sm font-medium mb-1">
Branch name
</label>
<input
id="branch-name"
value={productionBranch}
onChange={(e) => setProductionBranch(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
/>
</div>
<button
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors"
onClick={handleSaveBranch}
disabled={isSavingBranch}
>
Save
</button>
</div>
</div>
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Deploy webhooks</h2>
<p className="text-sm text-gray-400 mb-4">
Webhooks configured to trigger when there is a change in a project's build or deployment status.
</p>
<div className="space-y-4">
<div>
<label htmlFor="webhook-url" className="block text-sm font-medium mb-1">
Webhook URL
</label>
<div className="flex">
<input
id="webhook-url"
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://"
className="flex-1 px-3 py-2 rounded-l-md bg-gray-900 border border-gray-700 text-white"
/>
<button
className="px-4 py-2 border border-gray-600 border-l-0 rounded-r-md hover:bg-gray-800 transition-colors"
onClick={handleSaveWebhook}
disabled={isSavingWebhook}
>
Save
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { useParams } from 'next/navigation';
import { PageWrapper } from "@/components/foundation";
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import GitPage from "./GitPage";
import { useRouter } from 'next/navigation';
import { useRepoData } from '@/hooks/useRepoData';
export default function GitIntegrationsPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const provider = params.provider as string;
// Use the hook to get repo data
const { repoData } = useRepoData(id);
// Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => {
const basePath = `/projects/${provider}/ps/${id}`;
switch (value) {
case 'overview':
router.push(basePath);
break;
case 'deployment':
router.push(`${basePath}/dep`);
break;
case 'settings':
router.push(`${basePath}/set`);
break;
case 'git':
router.push(`${basePath}/int`);
break;
case 'env-vars':
router.push(`${basePath}/set/env`);
break;
}
};
return (
<PageWrapper
header={{
title: repoData ? `${repoData.name}` : 'Project Settings',
actions: [
{
label: 'Open repo',
href: repoData?.html_url || '#',
icon: 'external-link',
external: true
},
{
label: 'View app',
href: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : '#',
icon: 'external-link',
external: true
}
]
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full">
{/* Tabs navigation */}
<Tabs defaultValue="git" className="w-full" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="deployment">Deployment</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="git">Git</TabsTrigger>
<TabsTrigger value="env-vars">Environment Variables</TabsTrigger>
</TabsList>
</Tabs>
{/* Git content */}
<div className="mt-6">
<GitPage />
</div>
</div>
</PageWrapper>
);
}

View File

@ -0,0 +1,66 @@
'use client'
import { PageWrapper } from "@/components/foundation"
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'
import { useRouter } from 'next/navigation'
interface PageProps {
params: {
id: string
provider: string
orgSlug: string
}
}
const Page = ({ params }: PageProps) => {
const router = useRouter();
// Mock data for the project
// Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => {
const basePath = `/projects/${params.provider}/ps/${params.id}`;
switch (value) {
case 'overview':
router.push(basePath);
break;
case 'deployment':
router.push(`${basePath}/dep`);
break;
case 'settings':
router.push(`${basePath}/set`);
break;
case 'git':
router.push(`${basePath}/int`);
break;
case 'env-vars':
router.push(`${basePath}/set/env`);
break;
}
};
return (
<PageWrapper
header={{
title: `Project Name`,
actions: [{ label: 'Open repo', href: '/projects/create' }, { label: 'View app', href: '/projects/create' }]
}}
layout="bento" // Use bento layout to override max width
className="pb-0"
>
<div className="md:col-span-3 w-full"> {/* Take full width in bento grid */}
{/* Tabs navigation */}
<Tabs defaultValue="overview" className="w-full" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="deployment">Deployment</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="git">Git</TabsTrigger>
<TabsTrigger value="env-vars">Environment Variables</TabsTrigger>
</TabsList>
</Tabs>
</div>
</PageWrapper>
)
}
export default Page

View File

@ -0,0 +1,12 @@
const Page = () => {
return (
<div>
<h1>
Hello from
(web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/cf
</h1>
</div>
)
}
export default Page

View File

@ -0,0 +1,12 @@
const Page = () => {
return (
<div>
<h1>
Hello from
(web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf
</h1>
</div>
)
}
export default Page

View File

@ -0,0 +1,415 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay";
import { PlusIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon, TrashIcon } from "lucide-react";
interface EnvVarItem {
key: string;
value: string;
isEditing?: boolean;
}
interface EnvGroupProps {
title: string;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
varCount: number;
}
function EnvGroup({ title, isOpen, onToggle, children, varCount }: EnvGroupProps) {
return (
<div className="border-b border-gray-800 last:border-b-0">
<div
className="flex items-center justify-between py-4 cursor-pointer"
onClick={onToggle}
>
<div className="flex items-center space-x-2">
<h3 className="text-lg font-medium">{title}</h3>
<span className="text-sm text-gray-400">({varCount})</span>
</div>
<button className="p-1">
{isOpen ? <ChevronUpIcon size={18} /> : <ChevronDownIcon size={18} />}
</button>
</div>
{isOpen && (
<div className="pb-4">
{children}
</div>
)}
</div>
);
}
export default function EnvVarsPage() {
const params = useParams();
const { provider, id } = params;
const [isAddingVar, setIsAddingVar] = useState(false);
const [newVarKey, setNewVarKey] = useState("");
const [newVarValue, setNewVarValue] = useState("");
const [isSaving, setIsSaving] = useState(false);
// Group states
const [productionOpen, setProductionOpen] = useState(true);
const [previewOpen, setPreviewOpen] = useState(true);
const [deploymentOpen, setDeploymentOpen] = useState(true);
// Environment variables
const [productionVars, setProductionVars] = useState<EnvVarItem[]>([]);
const [previewVars, setPreviewVars] = useState<EnvVarItem[]>([
{ key: "TEST_KEY", value: "1" }
]);
const [deploymentVars, setDeploymentVars] = useState<EnvVarItem[]>([]);
// Checkboxes for environment selection
const [envSelection, setEnvSelection] = useState({
production: true,
preview: true,
development: true
});
const handleEnvSelectionChange = (env: 'production' | 'preview' | 'development') => {
setEnvSelection({
...envSelection,
[env]: !envSelection[env]
});
};
const addVariable = () => {
if (!newVarKey.trim() || !newVarValue.trim()) return;
const newVar = { key: newVarKey, value: newVarValue };
if (envSelection.production) {
setProductionVars([...productionVars, { ...newVar }]);
}
if (envSelection.preview) {
setPreviewVars([...previewVars, { ...newVar }]);
}
if (envSelection.development) {
setDeploymentVars([...deploymentVars, { ...newVar }]);
}
// Reset form
setNewVarKey("");
setNewVarValue("");
setIsAddingVar(false);
};
const cancelAddVariable = () => {
setNewVarKey("");
setNewVarValue("");
setIsAddingVar(false);
};
const removeVariable = (env: 'production' | 'preview' | 'development', index: number) => {
if (env === 'production') {
setProductionVars(productionVars.filter((_, i) => i !== index));
} else if (env === 'preview') {
setPreviewVars(previewVars.filter((_, i) => i !== index));
} else if (env === 'development') {
setDeploymentVars(deploymentVars.filter((_, i) => i !== index));
}
};
const editVariable = (env: 'production' | 'preview' | 'development', index: number) => {
if (env === 'production') {
const updatedVars = [...productionVars];
updatedVars[index] = { ...updatedVars[index], isEditing: true };
setProductionVars(updatedVars);
} else if (env === 'preview') {
const updatedVars = [...previewVars];
updatedVars[index] = { ...updatedVars[index], isEditing: true };
setPreviewVars(updatedVars);
} else if (env === 'development') {
const updatedVars = [...deploymentVars];
updatedVars[index] = { ...updatedVars[index], isEditing: true };
setDeploymentVars(updatedVars);
}
};
const updateVariable = (env: 'production' | 'preview' | 'development', index: number, key: string, value: string) => {
if (env === 'production') {
const updatedVars = [...productionVars];
updatedVars[index] = { key, value, isEditing: false };
setProductionVars(updatedVars);
} else if (env === 'preview') {
const updatedVars = [...previewVars];
updatedVars[index] = { key, value, isEditing: false };
setPreviewVars(updatedVars);
} else if (env === 'development') {
const updatedVars = [...deploymentVars];
updatedVars[index] = { key, value, isEditing: false };
setDeploymentVars(updatedVars);
}
};
const saveChanges = async () => {
try {
setIsSaving(true);
// Save environment variables
console.log("Saving environment variables:", {
production: productionVars,
preview: previewVars,
deployment: deploymentVars
});
// Implement API call to save environment variables
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
// Show success notification
} catch (error) {
console.error("Failed to save environment variables:", error);
// Show error notification
} finally {
setIsSaving(false);
}
};
const renderEnvVarRow = (env: 'production' | 'preview' | 'development', variable: EnvVarItem, index: number) => {
if (variable.isEditing) {
return (
<div key={index} className="flex items-center space-x-2 mb-2">
<input
className="flex-1 px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
value={variable.key}
onChange={(e) => {
const updatedVars = env === 'production'
? [...productionVars]
: env === 'preview'
? [...previewVars]
: [...deploymentVars];
updatedVars[index] = { ...updatedVars[index], key: e.target.value };
if (env === 'production') setProductionVars(updatedVars);
else if (env === 'preview') setPreviewVars(updatedVars);
else setDeploymentVars(updatedVars);
}}
placeholder="KEY"
/>
<input
className="flex-1 px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
value={variable.value}
onChange={(e) => {
const updatedVars = env === 'production'
? [...productionVars]
: env === 'preview'
? [...previewVars]
: [...deploymentVars];
updatedVars[index] = { ...updatedVars[index], value: e.target.value };
if (env === 'production') setProductionVars(updatedVars);
else if (env === 'preview') setPreviewVars(updatedVars);
else setDeploymentVars(updatedVars);
}}
placeholder="Value"
/>
<button
className="p-2 hover:bg-gray-800 rounded-md"
onClick={() => updateVariable(env, index, variable.key, variable.value)}
>
Save
</button>
<button
className="p-2 hover:bg-gray-800 rounded-md"
onClick={() => {
const updatedVars = env === 'production'
? [...productionVars]
: env === 'preview'
? [...previewVars]
: [...deploymentVars];
updatedVars[index] = { ...updatedVars[index], isEditing: false };
if (env === 'production') setProductionVars(updatedVars);
else if (env === 'preview') setPreviewVars(updatedVars);
else setDeploymentVars(updatedVars);
}}
>
Cancel
</button>
</div>
);
}
return (
<div key={index} className="flex items-center mb-2">
<div className="flex-1 flex items-center justify-between px-3 py-2 rounded-md bg-gray-900 border border-gray-700 mr-2">
<span>{variable.key}</span>
<span>{variable.value}</span>
</div>
<div className="flex space-x-1">
<button
className="p-2 hover:bg-gray-800 rounded-md"
onClick={() => editVariable(env, index)}
>
<PencilIcon size={16} />
</button>
<button
className="p-2 hover:bg-gray-800 rounded-md"
onClick={() => removeVariable(env, index)}
>
<TrashIcon size={16} />
</button>
</div>
</div>
);
};
return (
<>
{isSaving && <LoadingOverlay />}
<div className="space-y-6 w-full">
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Environment Variables</h2>
<p className="text-sm text-gray-400 mb-6">
A new deployment is required for your changes to take effect.
</p>
{!isAddingVar ? (
<button
className="flex items-center space-x-2 px-4 py-2 rounded-md border border-gray-700 bg-gray-900 hover:bg-gray-800 transition-colors"
onClick={() => setIsAddingVar(true)}
>
<PlusIcon size={16} />
<span>Create new variable</span>
</button>
) : (
<div className="space-y-4 mb-6 border border-gray-800 p-4 rounded-md">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Key</label>
<input
value={newVarKey}
onChange={(e) => setNewVarKey(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
placeholder="KEY"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Value</label>
<input
value={newVarValue}
onChange={(e) => setNewVarValue(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
placeholder="Value"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Environments</label>
<div className="space-y-2">
<div className="flex items-center">
<input
type="checkbox"
id="env-production"
checked={envSelection.production}
onChange={() => handleEnvSelectionChange('production')}
className="mr-2"
/>
<label htmlFor="env-production">Production</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="env-preview"
checked={envSelection.preview}
onChange={() => handleEnvSelectionChange('preview')}
className="mr-2"
/>
<label htmlFor="env-preview">Preview</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="env-development"
checked={envSelection.development}
onChange={() => handleEnvSelectionChange('development')}
className="mr-2"
/>
<label htmlFor="env-development">Development</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
className="px-4 py-2 rounded-md border border-gray-700 hover:bg-gray-800 transition-colors"
onClick={cancelAddVariable}
>
Cancel
</button>
<button
className="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 transition-colors"
onClick={addVariable}
disabled={!newVarKey.trim() || !newVarValue.trim()}
>
Add Variable
</button>
</div>
</div>
)}
<div className="mt-6">
<EnvGroup
title="Production"
isOpen={productionOpen}
onToggle={() => setProductionOpen(!productionOpen)}
varCount={productionVars.length}
>
{productionVars.length > 0 ? (
<div className="space-y-2">
{productionVars.map((variable, index) => renderEnvVarRow('production', variable, index))}
</div>
) : (
<p className="text-sm text-gray-400">No variables defined</p>
)}
</EnvGroup>
<EnvGroup
title="Preview"
isOpen={previewOpen}
onToggle={() => setPreviewOpen(!previewOpen)}
varCount={previewVars.length}
>
{previewVars.length > 0 ? (
<div className="space-y-2">
{previewVars.map((variable, index) => renderEnvVarRow('preview', variable, index))}
</div>
) : (
<p className="text-sm text-gray-400">No variables defined</p>
)}
</EnvGroup>
<EnvGroup
title="Deployment"
isOpen={deploymentOpen}
onToggle={() => setDeploymentOpen(!deploymentOpen)}
varCount={deploymentVars.length}
>
{deploymentVars.length > 0 ? (
<div className="space-y-2">
{deploymentVars.map((variable, index) => renderEnvVarRow('development', variable, index))}
</div>
) : (
<p className="text-sm text-gray-400">No variables defined</p>
)}
</EnvGroup>
</div>
<div className="mt-6">
<button
className="px-4 py-2 rounded-md bg-gray-800 hover:bg-gray-700 transition-colors"
onClick={saveChanges}
disabled={isSaving}
>
Save changes
</button>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,83 @@
'use client';
import { useParams } from 'next/navigation';
import { PageWrapper } from "@/components/foundation";
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import EnvVarsPage from "./EnvVarsPage";
import { useRouter } from 'next/navigation';
import { useRepoData } from '@/hooks/useRepoData';
export default function EnvironmentVariablesPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const provider = params.provider as string;
// Use the hook to get repo data
const { repoData } = useRepoData(id);
// Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => {
const basePath = `/projects/${provider}/ps/${id}`;
switch (value) {
case 'overview':
router.push(basePath);
break;
case 'deployment':
router.push(`${basePath}/dep`);
break;
case 'settings':
router.push(`${basePath}/set`);
break;
case 'git':
router.push(`${basePath}/int`);
break;
case 'env-vars':
router.push(`${basePath}/set/env`);
break;
}
};
return (
<PageWrapper
header={{
title: repoData ? `${repoData.name}` : 'Project Settings',
actions: [
{
label: 'Open repo',
href: repoData?.html_url || '#',
icon: 'external-link',
external: true
},
{
label: 'View app',
href: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : '#',
icon: 'external-link',
external: true
}
]
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full">
{/* Tabs navigation */}
<Tabs defaultValue="env-vars" className="w-full" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="deployment">Deployment</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="git">Git</TabsTrigger>
<TabsTrigger value="env-vars">Environment Variables</TabsTrigger>
</TabsList>
</Tabs>
{/* Environment Variables content */}
<div className="mt-6">
<EnvVarsPage />
</div>
</div>
</PageWrapper>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { useParams } from 'next/navigation';
import { PageWrapper } from "@/components/foundation";
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import GitPage from "../../../(integrations)/int/GitPage";
import { useRouter } from 'next/navigation';
import { useRepoData } from '@/hooks/useRepoData';
export default function GitIntegrationsPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const provider = params.provider as string;
// Use the hook to get repo data
const { repoData } = useRepoData(id);
// Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => {
const basePath = `/projects/${provider}/ps/${id}`;
switch (value) {
case 'overview':
router.push(basePath);
break;
case 'deployment':
router.push(`${basePath}/dep`);
break;
case 'settings':
router.push(`${basePath}/set`);
break;
case 'git':
router.push(`${basePath}/int`);
break;
case 'env-vars':
router.push(`${basePath}/set/env`);
break;
}
};
return (
<PageWrapper
header={{
title: repoData ? `${repoData.name}` : 'Project Settings',
actions: [
{
label: 'Open repo',
href: repoData?.html_url || '#',
icon: 'external-link',
external: true
},
{
label: 'View app',
href: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : '#',
icon: 'external-link',
external: true
}
]
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full">
{/* Tabs navigation */}
<Tabs defaultValue="git" className="w-full" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="deployment">Deployment</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="git">Git</TabsTrigger>
<TabsTrigger value="env-vars">Environment Variables</TabsTrigger>
</TabsList>
</Tabs>
{/* Git content */}
<div className="mt-6">
<GitPage />
</div>
</div>
</PageWrapper>
);
}

View File

@ -0,0 +1,274 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { Clipboard } from "lucide-react";
import { Dropdown } from "@/components/core/dropdown";
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay";
import { useRepoData } from "@/hooks/useRepoData";
// Create a simple modal component
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer?: React.ReactNode;
}
function Modal({ isOpen, onClose, title, children, footer }: ModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-gray-900 border border-gray-700 rounded-lg w-full max-w-md p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white">
&times;
</button>
</div>
<div className="mb-6">{children}</div>
{footer && <div className="flex justify-end">{footer}</div>}
</div>
</div>
);
}
export default function ProjectSettingsPage() {
const router = useRouter();
const params = useParams();
const id = params?.id ? String(params.id) : '';
// Use the hook to get repo data
const { repoData, isLoading } = useRepoData(id);
const [projectName, setProjectName] = useState("");
const [projectDescription, setProjectDescription] = useState("");
const [projectId, setProjectId] = useState("");
const [selectedAccount, setSelectedAccount] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// Update form values when project data is loaded
useEffect(() => {
if (repoData) {
setProjectName(repoData.name || "");
setProjectDescription(repoData.description || "");
setProjectId(repoData.id?.toString() || "");
}
}, [repoData]);
const accountOptions = [
{ label: "Personal Account", value: "account1" },
{ label: "Team Account", value: "account2" }
];
const handleSave = async () => {
try {
setIsSaving(true);
console.log("Saving project info:", { projectName, projectDescription });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Show success notification - in a real app you'd use a toast library
console.log("Project updated successfully");
} catch (error) {
console.error("Failed to save project info:", error);
// Show error notification
} finally {
setIsSaving(false);
}
};
const handleTransfer = async () => {
try {
setIsTransferring(true);
// Transfer project to selected account
console.log("Transferring project to:", selectedAccount);
// Implement API call to transfer project
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
// After successful transfer, navigate back to projects list
router.push("/dashboard/projects");
} catch (error) {
console.error("Failed to transfer project:", error);
// Show error notification
} finally {
setIsTransferring(false);
}
};
const handleDelete = async () => {
try {
setIsDeleting(true);
// Delete project
console.log("Deleting project");
// Implement API call to delete project
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
// After successful deletion, navigate back to projects list
router.push("/dashboard/projects");
} catch (error) {
console.error("Failed to delete project:", error);
// Show error notification
} finally {
setIsDeleting(false);
setIsDeleteModalOpen(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const DeleteModalFooter = (
<div className="flex space-x-2">
<button
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-md text-white"
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
>
Cancel
</button>
<button
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-md text-white"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</div>
);
if (isLoading) {
return <LoadingOverlay />;
}
return (
<>
{(isSaving || isTransferring || isDeleting) && <LoadingOverlay />}
<div className="space-y-8 w-full">
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Project Info</h2>
<div className="space-y-4">
<div>
<label htmlFor="appName" className="block text-sm font-medium mb-1">
App name
</label>
<input
id="appName"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
Description
</label>
<input
id="description"
value={projectDescription}
onChange={(e) => setProjectDescription(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
/>
</div>
<div>
<label htmlFor="projectId" className="block text-sm font-medium mb-1">
Project ID
</label>
<div className="relative">
<input
id="projectId"
value={projectId}
readOnly
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white pr-10"
/>
<button
className="absolute right-2 top-1/2 transform -translate-y-1/2"
onClick={() => copyToClipboard(projectId)}
aria-label="Copy project ID"
>
<Clipboard className="h-4 w-4" />
</button>
</div>
</div>
<button
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors mt-2"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
</div>
</div>
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Transfer Project</h2>
<div>
<label htmlFor="account" className="block text-sm font-medium mb-1">
Select account
</label>
<Dropdown
label="Select"
options={accountOptions}
selectedValue={selectedAccount}
onSelect={(value) => setSelectedAccount(value)}
className="w-full"
/>
<p className="text-sm text-gray-400 mt-2">
Transfer this app to your personal account or a team you are a member of.
</p>
<button
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors mt-4"
onClick={handleTransfer}
disabled={!selectedAccount || isTransferring}
>
{isTransferring ? "Transferring..." : "Transfer"}
</button>
</div>
</div>
<div className="rounded-lg border border-gray-800 border-red-900 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4 text-red-500">Delete Project</h2>
<p className="text-sm text-gray-400 mb-4">
The project will be permanently deleted, including its deployments and domains. This action is
irreversible and cannot be undone.
</p>
<button
className="px-4 py-2 bg-red-700 hover:bg-red-800 rounded-md text-white transition-colors"
onClick={() => setIsDeleteModalOpen(true)}
>
Delete project
</button>
<Modal
isOpen={isDeleteModalOpen}
onClose={() => !isDeleting && setIsDeleteModalOpen(false)}
title="Are you absolutely sure?"
footer={DeleteModalFooter}
>
<p className="text-gray-300">
This action cannot be undone. This will permanently delete the project
and all associated deployments and domains.
</p>
</Modal>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,84 @@
'use client';
import { useParams } from 'next/navigation';
import { PageWrapper } from "@/components/foundation";
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import ProjectSettingsPage from "./ProjectSettingsPage";
import { useRouter } from 'next/navigation';
import { useRepoData } from '@/hooks/useRepoData';
export default function SettingsPage() {
const router = useRouter();
const params = useParams();
// Safely unwrap params
const id = params?.id ? String(params.id) : '';
const provider = params?.provider ? String(params.provider) : '';
// Use the hook to get repo data
const { repoData } = useRepoData(id);
// Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => {
const basePath = `/projects/${provider}/ps/${id}`;
switch (value) {
case 'overview':
router.push(basePath);
break;
case 'deployment':
router.push(`${basePath}/dep`);
break;
case 'settings':
router.push(`${basePath}/set`);
break;
case 'git':
router.push(`${basePath}/int`);
break;
case 'env-vars':
router.push(`${basePath}/set/env`);
break;
}
};
return (
<PageWrapper
header={{
title: repoData ? `${repoData.name}` : 'Project Settings',
actions: [
{
label: 'Open repo',
href: repoData?.html_url || '#',
icon: 'external-link',
external: true
},
{
label: 'View app',
href: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : '#',
icon: 'external-link',
external: true
}
]
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full">
{/* Tabs navigation */}
<Tabs defaultValue="settings" className="w-full" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="deployment">Deployment</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="git">Git</TabsTrigger>
<TabsTrigger value="env-vars">Environment Variables</TabsTrigger>
</TabsList>
</Tabs>
{/* Settings content */}
<div className="mt-6">
<ProjectSettingsPage />
</div>
</div>
</PageWrapper>
);
}

View File

@ -0,0 +1,159 @@
'use client'
import { useEffect, useState } from 'react'
import { PageWrapper } from '@/components/foundation'
import { FixedProjectCard } from '@/components/projects/project/ProjectCard/FixedProjectCard'
import { Button } from '@workspace/ui/components/button'
import { Shapes } from 'lucide-react'
import { useAuth } from '@clerk/nextjs'
interface Deployment {
id: string
name: string
repositoryId: string
status: 'running' | 'complete' | 'failed'
url?: string
branch: string
createdAt: string
createdBy: {
name: string
}
}
export default function ProjectsPage() {
const [deployments, setDeployments] = useState<Deployment[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const { isLoaded: isAuthLoaded, userId } = useAuth()
useEffect(() => {
async function fetchDeployments() {
if (!isAuthLoaded) {
return;
}
setIsLoading(true);
try {
if (!userId) {
setError('Not authenticated');
return;
}
// In a real implementation, you would query your GraphQL backend
// For now, we'll mock some deployments
const mockDeployments: Deployment[] = [
{
id: 'dep_abc123',
name: 'My Project',
repositoryId: '123456',
status: 'complete',
url: 'https://my-project.example.com',
branch: 'main',
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
createdBy: {
name: 'John Doe'
}
},
{
id: 'dep_def456',
name: 'Another Project',
repositoryId: '789012',
status: 'running',
branch: 'develop',
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
createdBy: {
name: 'Jane Smith'
}
}
];
setDeployments(mockDeployments);
} catch (err) {
console.error('Error fetching deployments:', err)
setError('Failed to fetch deployments')
} finally {
setIsLoading(false)
}
}
fetchDeployments()
}, [isAuthLoaded, userId]);
return (
<PageWrapper
header={{
title: 'Projects',
actions: [{ label: 'Create Project', href: '/projects/create' }]
}}
layout="bento"
className="pb-0"
>
{isLoading ? (
<div className="md:col-span-3 flex justify-center items-center min-h-[600px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
) : error ? (
<div className="md:col-span-3 border border-gray-800 rounded-lg min-h-[600px] flex flex-col items-center justify-center p-6">
<div className="mb-6">
<div className="flex flex-col items-center">
<Shapes size={64} className="stroke-current" />
</div>
</div>
<h2 className="text-xl font-semibold mb-2">Error: {error}</h2>
<p className="text-gray-400 text-center max-w-md mb-6">
There was an error loading your deployments.
</p>
<Button
className="bg-white text-black hover:bg-gray-200"
onClick={() => window.location.reload()}
>
Try Again
</Button>
</div>
) : deployments.length === 0 ? (
<div className="md:col-span-3 border border-gray-800 rounded-lg min-h-[600px] flex flex-col items-center justify-center p-6">
<div className="mb-6">
<div className="flex flex-col items-center">
<Shapes size={64} className="stroke-current" />
</div>
</div>
<h2 className="text-xl font-semibold mb-2">Deploy your first app</h2>
<p className="text-gray-400 text-center max-w-md mb-6">
You haven't deployed any projects yet. Start by importing a repository from your GitHub account.
</p>
<Button
className="bg-white text-black hover:bg-gray-200"
onClick={() => window.location.href = '/projects/create'}
>
Create Project
</Button>
</div>
) : (
<div className="md:col-span-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{deployments.map((deployment) => (
<FixedProjectCard
project={{
id: deployment.id,
name: deployment.name,
deployments: [{
applicationDeploymentRecordData: {
url: deployment.url
},
branch: deployment.branch,
createdAt: deployment.createdAt,
createdBy: deployment.createdBy
}]
}}
key={deployment.id}
status={deployment.status === 'complete' ? 'success' :
deployment.status === 'running' ? 'pending' : 'error'}
/>
))}
</div>
</div>
)}
</PageWrapper>
)
}

View File

@ -0,0 +1,14 @@
import type { ReactNode } from 'react'
interface LayoutProps {
children: ReactNode
params: {
id: string
provider: string
orgSlug: string
}
}
export default function ProjectLayout({ children }: LayoutProps) {
return <div className="flex flex-col min-h-0 flex-1">{children}</div>
}

View File

@ -0,0 +1,14 @@
import { PageWrapper } from '@/components/foundation'
export default function Loading() {
return (
<PageWrapper>
<div className="animate-pulse space-y-4">
<div className="h-12 w-12 bg-gray-200 rounded-full" />
<div className="h-4 w-48 bg-gray-200 rounded" />
<div className="h-4 w-64 bg-gray-200 rounded" />
<div className="h-4 w-32 bg-gray-200 rounded" />
</div>
</PageWrapper>
)
}

View File

@ -0,0 +1,267 @@
'use client';
import { useParams } from 'next/navigation';
import { PageWrapper } from '@/components/foundation';
import { getInitials } from '@/utils/getInitials';
import { relativeTimeMs } from '@/utils/time';
import {
Avatar,
AvatarFallback} from '@workspace/ui/components/avatar';
import { Button } from '@workspace/ui/components/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Activity, Clock, GitBranch, ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRepoData } from '@/hooks/useRepoData';
import { useEffect, useState } from 'react';
export default function ProjectOverviewPage() {
const router = useRouter();
const params = useParams();
// Safely unwrap params
const id = params?.id ? String(params.id) : '';
const provider = params?.provider ? String(params.provider) : '';
// Use the hook to get repo data
const { repoData } = useRepoData(id);
// Default deployment details
const [deploymentUrl, setDeploymentUrl] = useState('');
const [deploymentDate, setDeploymentDate] = useState(Date.now() - 60 * 60 * 1000); // 1 hour ago
const [deployedBy, setDeployedBy] = useState('');
const [projectName, setProjectName] = useState('');
const [branch, setBranch] = useState('main');
// Update details when repo data is loaded
useEffect(() => {
if (repoData) {
setProjectName(repoData.name);
setBranch(repoData.default_branch || 'main');
setDeployedBy(repoData.owner?.login || 'username');
// Create a deployment URL based on the repo name
setDeploymentUrl(`https://${repoData.name.toLowerCase()}.example.com`);
}
}, [repoData]);
// Auction data
const auctionId = 'laconic1sdfjwei4jfkasifgjiai45ioasjf5jjjafij355';
// Activities data
const activities = [
{
username: deployedBy || 'username',
branch: branch,
action: 'deploy: source cargo',
time: '5 minutes ago'
},
{
username: deployedBy || 'username',
branch: branch,
action: 'bump',
time: '5 minutes ago'
},
{
username: deployedBy || 'username',
branch: branch,
action: 'version: update version',
time: '5 minutes ago'
},
{
username: deployedBy || 'username',
branch: branch,
action: 'build: updates',
time: '5 minutes ago'
}
];
// Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => {
const basePath = `/projects/${provider}/ps/${id}`;
switch (value) {
case 'overview':
router.push(basePath);
break;
case 'deployment':
router.push(`${basePath}/dep`);
break;
case 'settings':
router.push(`${basePath}/set`);
break;
case 'git':
router.push(`${basePath}/int`);
break;
case 'env-vars':
router.push(`${basePath}/set/env`);
break;
}
};
return (
<PageWrapper
header={{
title: projectName || 'Project Overview',
actions: [
{
label: 'Open repo',
href: repoData?.html_url || '#',
icon: 'external-link',
external: true
},
{
label: 'View app',
href: deploymentUrl || '#',
icon: 'external-link',
external: true
}
]
}}
layout="bento" // Use bento layout to override max width
className="pb-0"
>
<div className="md:col-span-3 w-full"> {/* Take full width in bento grid */}
{/* Tabs navigation */}
<Tabs defaultValue="overview" className="w-full" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="deployment">Deployment</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="git">Git</TabsTrigger>
<TabsTrigger value="env-vars">Environment Variables</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="pt-6">
{/* Main content card (containing project info and auction details) */}
<div className="bg-background border border-border rounded-lg overflow-hidden mb-8">
{/* Project info section */}
<div className="p-6">
<div className="flex items-center">
<Avatar className="h-10 w-10 mr-4 bg-blue-600">
<AvatarFallback>{getInitials(projectName || '')}</AvatarFallback>
</Avatar>
<div>
<h2 className="text-lg font-medium">{projectName}</h2>
<p className="text-muted-foreground">
{deploymentUrl.replace(/^https?:\/\//, '')}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div>
<div className="flex items-center mb-2">
<GitBranch className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground text-sm">Source</span>
</div>
<div className="flex items-center">
<GitBranch className="h-4 w-4 mr-2" />
<span>{branch}</span>
</div>
</div>
<div>
<div className="flex items-center mb-2">
<ExternalLink className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground text-sm">Deployment URL</span>
</div>
<Link
href={deploymentUrl}
className="text-primary hover:underline flex items-center"
target="_blank"
>
{deploymentUrl}
</Link>
</div>
</div>
<div className="mt-6">
<div className="flex items-center mb-2">
<Clock className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground text-sm">Deployment date</span>
</div>
<div className="flex items-center">
<span className="mr-2">
{relativeTimeMs(deploymentDate)}
</span>
<span className="mr-2">by</span>
<Avatar className="h-5 w-5 mr-2">
<AvatarFallback>{getInitials(deployedBy)}</AvatarFallback>
</Avatar>
<span>{deployedBy}</span>
</div>
</div>
{/* Divider between project info and auction details */}
<div className="border-t border-border my-6"></div>
{/* Auction Details section */}
<div>
<h3 className="text-lg font-medium mb-6">Auction Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="text-sm text-muted-foreground mb-1">Auction ID</h4>
<p className="text-sm font-medium break-all">{auctionId}</p>
</div>
<div>
<h4 className="text-sm text-muted-foreground mb-1">Auction Status</h4>
<div className="inline-block px-2 py-0.5 bg-green-700/20 text-green-400 text-xs font-medium rounded">
COMPLETED
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div>
<h4 className="text-sm text-muted-foreground mb-1">Deployer LRNs</h4>
<p className="text-sm font-medium break-all">{auctionId}</p>
</div>
<div>
<h4 className="text-sm text-muted-foreground mb-1">Deployer Funds Status</h4>
<div className="inline-block px-2 py-0.5 bg-blue-700/20 text-blue-400 text-xs font-medium rounded">
RELEASED
</div>
</div>
</div>
<div className="mt-6">
<Button variant="outline" size="sm">View details</Button>
</div>
</div>
</div>
</div>
{/* Activity section - not in a card */}
<div className="mt-8">
<h3 className="text-lg font-medium mb-6 flex items-center">
<Activity className="mr-2 h-4 w-4" />
Activity
</h3>
<div className="space-y-4">
{activities.map((activity, index) => (
<div key={index} className="flex items-start">
<div className="text-muted-foreground mr-2"></div>
<div className="flex-1">
<span className="text-sm mr-2">{activity.username}</span>
<GitBranch className="inline h-3 w-3 text-muted-foreground mx-1" />
<span className="text-sm text-muted-foreground mr-2">{activity.branch}</span>
<span className="text-sm text-muted-foreground">{activity.action}</span>
</div>
<div className="text-sm text-muted-foreground">{activity.time}</div>
</div>
))}
</div>
</div>
</TabsContent>
{/* These content sections won't be shown - we'll navigate to respective pages instead */}
<TabsContent value="deployment"></TabsContent>
<TabsContent value="settings"></TabsContent>
<TabsContent value="git"></TabsContent>
<TabsContent value="env-vars"></TabsContent>
</Tabs>
</div>
</PageWrapper>
);
}

View File

@ -0,0 +1,30 @@
'use client'
import { Button } from '@workspace/ui/components/button'
import { useEffect } from 'react'
export default function ClientError({
error,
reset
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<div className="p-8 text-center">
<h2 className="text-xl font-bold mb-4">Something went wrong!</h2>
<p className="mb-4 text-gray-600">{error.message}</p>
<Button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Try again
</Button>
</div>
)
}

View File

@ -0,0 +1,11 @@
export default function Loading() {
return (
<div className="flex items-center justify-center p-12">
<div className="animate-pulse text-center">
<div className="h-6 w-24 bg-gray-200 rounded mb-4 mx-auto" />
<div className="h-32 w-full max-w-md bg-gray-200 rounded" />
</div>
</div>
)
}

View File

@ -0,0 +1,161 @@
'use client'
import { PageWrapper } from '@/components/foundation'
import CheckBalanceIframe from '@/components/iframe/check-balance-iframe/CheckBalanceIframe'
import type { Project } from '@octokit/webhooks-types'
import { FixedProjectCard } from '@/components/projects/project/ProjectCard/FixedProjectCard'
import { Button } from '@workspace/ui/components/button'
import { useEffect, useState } from 'react'
import { Shapes } from 'lucide-react'
import { useAuth, useUser } from '@clerk/nextjs'
import { useRepoData } from '@/hooks/useRepoData'
interface ProjectData {
id: string
name: string
icon?: string
deployments: any[]
// Additional fields from GitHub repo
full_name?: string
html_url?: string
updated_at?: string
default_branch?: string
}
export default function ProjectsPage() {
const [, setIsBalanceSufficient] = useState<boolean>()
const [projects, setProjects] = useState<Project[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const { isLoaded: isAuthLoaded, userId } = useAuth()
const { isLoaded: isUserLoaded, user } = useUser()
// Use the hook to fetch all repos (with an empty ID to get all)
const { repoData: allRepos, isLoading: reposLoading, error: reposError } = useRepoData('');
const handleConnectGitHub = () => {
window.open('https://accounts.clerk.dev/user', '_blank');
}
useEffect(() => {
// Process repos data when it's loaded
if (!reposLoading && allRepos) {
// Transform GitHub repos to match ProjectData interface
const projectData: ProjectData[] = allRepos.map((repo: any) => ({
id: repo.id.toString(),
name: repo.name,
full_name: repo.full_name,
// Create a deployment object that matches your existing structure
deployments: [
{
applicationDeploymentRecordData: {
url: repo.html_url
},
branch: repo.default_branch,
createdAt: repo.updated_at,
createdBy: {
name: repo.owner?.login || 'Unknown'
}
}
]
}));
setProjects(projectData);
setIsLoading(false);
} else if (!reposLoading && reposError) {
setError(reposError);
setIsLoading(false);
}
}, [allRepos, reposLoading, reposError]);
return (
<PageWrapper
header={{
title: 'Projects',
actions: [{ label: 'Create Project', href: '/projects/create' }]
}}
layout="bento"
className="pb-0"
>
{isLoading ? (
// Full width loading spinner in bento layout
<div className="md:col-span-3 flex justify-center items-center min-h-[600px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
) : error ? (
// Full width error state in bento layout
<div className="md:col-span-3 border border-gray-800 rounded-lg min-h-[600px] flex flex-col items-center justify-center p-6">
<div className="mb-6">
<div className="flex flex-col items-center">
<Shapes size={64} className="stroke-current" />
</div>
</div>
<h2 className="text-xl font-semibold mb-2">Error: {error}</h2>
<p className="text-gray-400 text-center max-w-md mb-6">
Please connect your GitHub account to see your repositories.
</p>
<Button
className="bg-white text-black hover:bg-gray-200 flex items-center"
onClick={handleConnectGitHub}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
Connect to GitHub
</Button>
</div>
) : projects.length === 0 ? (
// Full width empty state in bento layout
<div className="md:col-span-3 border border-gray-800 rounded-lg min-h-[600px] flex flex-col items-center justify-center p-6">
<div className="mb-6">
<div className="flex flex-col items-center">
<Shapes size={64} className="stroke-current" />
</div>
</div>
<h2 className="text-xl font-semibold mb-2">Deploy your first app</h2>
<p className="text-gray-400 text-center max-w-md mb-6">
Once connected, you can import a repository from your account or start with one of our templates.
</p>
<Button
className="bg-white text-black hover:bg-gray-200 flex items-center"
onClick={handleConnectGitHub}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
Connect to GitHub
</Button>
</div>
) : (
// Custom grid that spans the entire bento layout
<div className="md:col-span-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => (
<FixedProjectCard
project={project as any}
key={project.id}
status={project.deployments[0]?.branch ? 'success' : 'pending'}
/>
))}
</div>
</div>
)}
{/* Wrap in try/catch to prevent breaking if there are issues */}
{(() => {
try {
return (
<CheckBalanceIframe
onBalanceChange={setIsBalanceSufficient}
isPollingEnabled={false}
amount="1"
/>
);
} catch (error) {
console.error('Failed to render CheckBalanceIframe:', error);
return null;
}
})()}
</PageWrapper>
)
}

View File

@ -0,0 +1,142 @@
'use client'
import { PageWrapper } from '@/components/foundation/page-wrapper'
import { Button } from '@workspace/ui/components/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from '@workspace/ui/components/card'
/**
* BuyPrepaidService component allows users to buy prepaid services.
* It checks if the user's balance is sufficient and redirects them to the home page if it is.
*
* @returns {JSX.Element} A JSX element that renders the buy prepaid service page.
*/
const BuyPrepaidService = () => {
return (
<PageWrapper
header={{
title: 'Buy Prepaid Service',
subtitle: 'Choose a plan that fits your deployment needs'
}}
>
<div className=" mx-auto px-4 py-16 md:py-18 w-full">
<div className="mx-auto w-full min-w-fit">
<div className="text-center mb-12">
<h2 className="font-normal text-gray-400 tracking-tight sm:text-2xl text-foreground">
Laconic
</h2>
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl text-foreground">
Webapp Deployment Plans
</h1>
<p className="mt-4 text-muted-foreground">
Choose the perfect deployment plan for your needs. Scale your
applications with confidence.
</p>
</div>
<div className="grid gap-6 md:grid-cols-3 w-full">
{/* Basic Plan */}
<Card className="border border-gray-200">
<CardHeader>
<CardTitle>Basic</CardTitle>
<CardDescription>
A simple deployment option for small projects
</CardDescription>
<div className="mt-4">
<span className="text-3xl font-bold">$5.00</span>
<span className="text-muted-foreground ml-1">/month</span>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-6">
1 monthly webapp deployment
</p>
</CardContent>
<CardFooter>
<Button className="w-full" variant="outline" asChild>
<a
href="https://store.laconic.com/products/1-webapp-deployment"
target="_blank"
rel="noopener noreferrer"
>
Purchase plan
</a>
</Button>
</CardFooter>
</Card>
{/* Standard Plan */}
<Card className="border-2 border-primary relative">
<div className="absolute -top-3 left-0 right-0 mx-auto w-fit rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground">
Most popular
</div>
<CardHeader>
<CardTitle>Standard</CardTitle>
<CardDescription>
Perfect for growing projects and businesses
</CardDescription>
<div className="mt-4">
<span className="text-3xl font-bold">$50.00</span>
<span className="text-muted-foreground ml-1">/month</span>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-6">
10 monthly webapp deployments
</p>
</CardContent>
<CardFooter>
<Button className="w-full" asChild>
<a
href="https://store.laconic.com/products/10-webapp-deployments"
target="_blank"
rel="noopener noreferrer"
>
Purchase plan
</a>
</Button>
</CardFooter>
</Card>
{/* Premium Plan */}
<Card className="border border-gray-200">
<CardHeader>
<CardTitle>Premium</CardTitle>
<CardDescription>
For enterprises with high-volume needs
</CardDescription>
<div className="mt-4">
<span className="text-3xl font-bold">$500.00</span>
<span className="text-muted-foreground ml-1">/month</span>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-6">
100 monthly webapp deployments
</p>
</CardContent>
<CardFooter>
<Button className="w-full" variant="outline" asChild>
<a
href="https://store.laconic.com/products/100-webapp-deployments"
target="_blank"
rel="noopener noreferrer"
>
Purchase plan
</a>
</Button>
</CardFooter>
</Card>
</div>
</div>
</div>
</PageWrapper>
)
}
export default BuyPrepaidService

View File

@ -0,0 +1,13 @@
import type { Metadata } from 'next'
import BuyPrepaidService from './BuyServices'
export const metadata: Metadata = {
title: 'Buy Prepaid Service',
description: 'Buy prepaid service page description'
}
const Page = () => {
return <BuyPrepaidService />
}
export default Page

View File

@ -0,0 +1,19 @@
import { PageWrapper } from '@/components/foundation'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Store Page',
description: 'Store page description'
}
const Page = () => {
return (
<PageWrapper header={{ title: 'Store' }}>
<div>
<h1>Hello from store</h1>
</div>
</PageWrapper>
)
}
export default Page

View File

@ -0,0 +1,255 @@
'use client'
import type React from 'react'
import {
Bell,
ChevronRight,
CreditCard,
Globe,
HelpCircle,
Lock,
Shield,
User
} from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { ComingSoonOverlay } from '@/components/foundation'
import { Button } from '@workspace/ui/components/button'
import { Separator } from '@workspace/ui/components/separator'
import { useRouter } from 'next/navigation'
/**
* Settings category item component
* Renders a single settings category with an icon and label
*/
interface SettingsCategoryProps {
/** The icon to display for this category */
icon: React.ReactNode
/** The label text for this category */
label: string
/** Whether this category is currently active */
isActive?: boolean
}
function SettingsCategory({
icon,
label,
isActive = false
}: SettingsCategoryProps) {
return (
<Link
href="#"
className={`flex items-center gap-3 px-4 py-2 rounded-md transition-colors ${
isActive ? 'bg-muted font-medium' : 'hover:bg-muted/50'
}`}
>
{icon}
<span>{label}</span>
{isActive && <ChevronRight className="ml-auto h-4 w-4" />}
</Link>
)
}
/**
* Settings page component
* Displays a simple settings interface with categories and placeholder content
*/
export default function SettingsPage() {
const router = useRouter()
const [activeCategory] = useState('profile')
const [formState, setFormState] = useState({
darkTheme: false,
autoDetectTimezone: true,
allowAnalytics: true
})
/**
* Handle checkbox changes
*/
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { id, checked } = e.target
setFormState((prev) => ({
...prev,
[id]: checked
}))
}
return (
<div className="min-h-screen bg-background">
<ComingSoonOverlay
message="Settings configuration will be available in the next release."
routerAction={() => router.back()}
/>
{/* Header */}
<header className="border-b">
<div className="container py-4">
<div className="flex items-center">
<Link href="/" className="flex items-center gap-2">
<Globe className="h-6 w-6" />
<span className="font-bold">Laconic Deploy</span>
</Link>
<h1 className="text-xl font-semibold ml-8">Settings</h1>
</div>
</div>
</header>
<div className="container py-8">
<div className="grid grid-cols-1 md:grid-cols-[250px_1fr] gap-8">
{/* Sidebar */}
<nav className="space-y-1">
<SettingsCategory
icon={<User className="h-5 w-5" />}
label="Profile"
isActive={activeCategory === 'profile'}
/>
<SettingsCategory
icon={<Bell className="h-5 w-5" />}
label="Notifications"
/>
<SettingsCategory
icon={<Lock className="h-5 w-5" />}
label="Password & Security"
/>
<SettingsCategory
icon={<Shield className="h-5 w-5" />}
label="Access Control"
/>
<SettingsCategory
icon={<CreditCard className="h-5 w-5" />}
label="Billing"
/>
<SettingsCategory
icon={<HelpCircle className="h-5 w-5" />}
label="Help & Support"
/>
</nav>
{/* Main content */}
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">
Profile Settings
</h2>
<p className="text-muted-foreground">
Manage your account information and preferences.
</p>
</div>
<Separator />
<div className="space-y-4">
<div className="grid gap-2">
<h3 className="text-lg font-medium">Personal Information</h3>
<p className="text-sm text-muted-foreground">
Update your personal details and how we contact you.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Full Name
</label>
<input
id="name"
type="text"
placeholder="John Doe"
className="w-full px-3 py-2 border rounded-md"
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email Address
</label>
<input
id="email"
type="email"
placeholder="john@example.com"
className="w-full px-3 py-2 border rounded-md"
/>
</div>
</div>
<div className="flex justify-end">
<Button>Save Changes</Button>
</div>
</div>
<Separator />
<div className="space-y-4">
<div className="grid gap-2">
<h3 className="text-lg font-medium">Preferences</h3>
<p className="text-sm text-muted-foreground">
Customize your experience with Laconic Deploy.
</p>
</div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<input
id="darkTheme"
type="checkbox"
className="rounded"
checked={formState.darkTheme}
onChange={handleCheckboxChange}
/>
<label htmlFor="darkTheme" className="text-sm font-medium">
Use dark theme
</label>
</div>
<div className="flex items-center space-x-2">
<input
id="autoDetectTimezone"
type="checkbox"
className="rounded"
checked={formState.autoDetectTimezone}
onChange={handleCheckboxChange}
/>
<label
htmlFor="autoDetectTimezone"
className="text-sm font-medium"
>
Automatically detect timezone
</label>
</div>
<div className="flex items-center space-x-2">
<input
id="allowAnalytics"
type="checkbox"
className="rounded"
checked={formState.allowAnalytics}
onChange={handleCheckboxChange}
/>
<label
htmlFor="allowAnalytics"
className="text-sm font-medium"
>
Allow anonymous usage data collection
</label>
</div>
</div>
<div className="flex justify-end">
<Button>Save Preferences</Button>
</div>
</div>
</div>
</div>
</div>
<footer className="border-t mt-auto">
<div className="container py-6">
<p className="text-center text-sm text-muted-foreground">
© {new Date().getFullYear()} Laconic Deploy. All rights reserved.
</p>
</div>
</footer>
</div>
)
}

View File

@ -0,0 +1,10 @@
import { PageWrapper } from '@/components/foundation'
import SupportPlaceholder from './SupportPlaceholder'
export default function SupportPage() {
return (
<PageWrapper header={{ title: 'Support' }}>
<SupportPlaceholder />
</PageWrapper>
)
}

View File

@ -0,0 +1,19 @@
import { PageWrapper } from '@/components/foundation'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Wallet Page',
description: 'Wallet page description'
}
const Page = () => {
return (
<PageWrapper header={{ title: 'Wallet' }}>
<div>
<h1>Hello from wallet</h1>
</div>
</PageWrapper>
)
}
export default Page

View File

@ -0,0 +1,11 @@
import type React from 'react'
interface LayoutProps {
children: React.ReactNode
}
const Layout: React.FC<LayoutProps> = async ({ children }) => {
return <main>{children}</main>
}
export default Layout

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