Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ const defs = {
authorize: withAdminSession,
fn: (_ctx, project) => ProjectsCollection.insert(project),
input: OBJ({
projectSlug: STR('The unique identifier for the project'),
projectName: STR('The name of the project'),
slug: STR('The unique identifier for the project'),
name: STR('The name of the project'),
teamId: STR('The ID of the team that owns the project'),
isPublic: BOOL('Is the project public?'),
repositoryUrl: optional(STR('The URL of the project repository')),
Expand All @@ -149,21 +149,21 @@ const defs = {
}),
'GET/api/project': route({
authorize: withUserSession,
fn: (_ctx, { projectSlug }) => {
const project = ProjectsCollection.get(projectSlug)
fn: (_ctx, { slug }) => {
const project = ProjectsCollection.get(slug)
if (!project) throw respond.NotFound({ message: 'Project not found' })
return project
},
input: OBJ({ projectSlug: STR('The slug of the project') }),
input: OBJ({ slug: STR('The slug of the project') }),
output: ProjectDef,
description: 'Get a project by ID',
}),
'PUT/api/project': route({
authorize: withAdminSession,
fn: (_ctx, input) => ProjectsCollection.update(input.projectSlug, input),
fn: (_ctx, input) => ProjectsCollection.update(input.slug, input),
input: OBJ({
projectSlug: STR('The unique identifier for the project'),
projectName: STR('The name of the project'),
slug: STR('The unique identifier for the project'),
name: STR('The name of the project'),
teamId: STR('The ID of the team that owns the project'),
isPublic: BOOL('Is the project public?'),
repositoryUrl: optional(STR('The URL of the project repository')),
Expand All @@ -173,13 +173,13 @@ const defs = {
}),
'DELETE/api/project': route({
authorize: withAdminSession,
fn: (_ctx, { projectSlug }) => {
const project = ProjectsCollection.get(projectSlug)
fn: (_ctx, { slug }) => {
const project = ProjectsCollection.get(slug)
if (!project) throw respond.NotFound({ message: 'Project not found' })
ProjectsCollection.delete(projectSlug)
ProjectsCollection.delete(slug)
return true
},
input: OBJ({ projectSlug: STR('The slug of the project') }),
input: OBJ({ slug: STR('The slug of the project') }),
output: BOOL('Indicates if the project was deleted'),
description: 'Delete a project by ID',
}),
Expand Down
25 changes: 21 additions & 4 deletions api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,24 @@ export const TeamDef = OBJ({
export type Team = Asserted<typeof TeamDef> & BaseRecord

export const ProjectDef = OBJ({
projectSlug: STR('The unique identifier for the project'),
projectName: STR('The name of the project'),
slug: STR('The unique identifier for the project'),
name: STR('The name of the project'),
teamId: STR('The ID of the team that owns the project'),
isPublic: BOOL('Is the project public?'),
repositoryUrl: optional(STR('The URL of the project repository')),
}, 'The project schema definition')
export type Project = Asserted<typeof ProjectDef> & BaseRecord

export const DeploymentDef = OBJ({
projectId: STR('The ID of the project this deployment belongs to'),
url: STR('The URL of the deployment'),
logsEnabled: BOOL('Are logs enabled for this deployment?'),
databaseEnabled: BOOL('Is the database enabled for this deployment?'),
sqlEndpoint: optional(STR('The SQL execution endpoint for the database')),
sqlToken: optional(STR('The security token for the SQL endpoint')),
}, 'The deployment schema definition')
export type Deployment = Asserted<typeof DeploymentDef> & BaseRecord

export const UsersCollection = await createCollection<User, 'userEmail'>(
{ name: 'users', primaryKey: 'userEmail' },
)
Expand All @@ -39,7 +49,14 @@ export const TeamsCollection = await createCollection<Team, 'teamId'>(

export const ProjectsCollection = await createCollection<
Project,
'projectSlug'
'slug'
>(
{ name: 'projects', primaryKey: 'slug' },
)

export const DeploymentsCollection = await createCollection<
Deployment & { tokenSalt: string },
'url'
>(
{ name: 'projects', primaryKey: 'projectSlug' },
{ name: 'deployments', primaryKey: 'url' },
)
12 changes: 6 additions & 6 deletions tasks/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,22 @@ const teams: Team[] = [

const projects: Omit<Project, 'createdAt'>[] = [
{
projectSlug: 'website-redesign',
projectName: 'Website Redesign',
slug: 'website-redesign',
name: 'Website Redesign',
teamId: 'frontend-devs',
isPublic: true,
repositoryUrl: 'https://github.com/example/website',
},
{
projectSlug: 'api-refactor',
projectName: 'API Refactor',
slug: 'api-refactor',
name: 'API Refactor',
teamId: 'backend-devs',
isPublic: false,
repositoryUrl: 'https://github.com/example/api',
},
{
projectSlug: 'design-system',
projectName: 'Design System',
slug: 'design-system',
name: 'Design System',
teamId: 'frontend-devs',
isPublic: true,
repositoryUrl: 'https://github.com/example/design-system',
Expand Down
45 changes: 45 additions & 0 deletions web/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { JSX } from 'preact'
import { SideBar } from './SideBar.tsx'

export const PageLayoutWithSideBar = (
{ children }: { children: JSX.Element | JSX.Element[] | null },
) => (
<div class='h-screen flex bg-bg'>
<SideBar />
<div class='flex-1 flex flex-col'>
{children}
</div>
</div>
)

export const PageLayout = (
{ children }: { children: JSX.Element | JSX.Element[] },
) => (
<div class='h-screen flex justify-center bg-bg'>
<div class='w-full max-w-7xl h-full bg-base-100 flex flex-col'>
{children}
</div>
</div>
)

export const PageHeader = (
{ children, className }: {
className?: string
children: JSX.Element | JSX.Element[]
},
) => (
<header
class={['px-4 sm:px-6 py-4 bg-surface border-b border-divider', className]
.join(' ')}
>
<div class='flex flex-col lg:flex-row justify-between items-start lg:items-center gap-3 sm:gap-4'>
{children}
</div>
</header>
)

export const PageContent = (
{ children }: { children: JSX.Element | JSX.Element[] },
) => (
<main class='flex-1 overflow-y-auto px-4 sm:px-6 py-6 pb-20'>{children}</main>
)
93 changes: 93 additions & 0 deletions web/components/SideBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
ChevronsLeft,
ChevronsRight,
HardDrive,
ListTodo,
LucideIcon,
Settings,
} from 'lucide-preact'
import { A, LinkProps, url } from '../lib/router.tsx'
import { user } from '../lib/session.ts'

const NavLink = (
{ icon: Icon, children, current, ...props }: LinkProps & {
current: boolean
icon: LucideIcon
},
) => (
<A
{...props}
class={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
current
? 'bg-primary/10 text-primary'
: 'text-base-content/70 hover:bg-base-300'
} ${url.params.sidebar_collapsed === 'true' ? 'justify-center' : ''}`}
replace
>
<Icon class='h-5 w-5' />
{url.params.sidebar_collapsed !== 'true' && <span>{children}</span>}
</A>
)

export const SideBar = () => {
const { nav, sidebar_collapsed } = url.params
const isCollapsed = sidebar_collapsed === 'true'
return (
<div
class={`relative h-full border-r border-base-300 bg-base-200 transition-all duration-300 ease-in-out ${
isCollapsed ? 'w-20' : 'w-64'
}`}
>
<div class='flex h-[calc(100vh-4rem)] flex-col'>
<div
class={`flex items-center border-b border-base-300 p-4 ${
isCollapsed ? 'justify-center' : 'justify-between'
}`}
>
{!isCollapsed && <h2 class='text-lg font-semibold'>Project</h2>}
<A
params={{
...url.params,
sidebar_collapsed: isCollapsed ? null : 'true',
}}
replace
class='rounded-lg p-2 text-base-content/70 hover:bg-base-300'
>
{isCollapsed ? <ChevronsRight /> : <ChevronsLeft />}
</A>
</div>
<nav class='flex-1 space-y-2 p-4'>
<NavLink
current={nav === 'deployment'}
params={{ ...url.params, nav: 'deployment' }}
icon={HardDrive}
>
Deployment
</NavLink>
<NavLink
current={nav === 'tasks'}
params={{ ...url.params, nav: 'tasks' }}
icon={ListTodo}
>
Tasks
</NavLink>
</nav>
<div
class={`border-t border-base-300 p-4 ${
user.data?.isAdmin ? '' : 'opacity-50 pointer-events-none'
}`}
>
<NavLink
current={nav === 'settings'}
{...user.data?.isAdmin
? { params: { ...url.params, nav: 'settings' } }
: {}}
icon={Settings}
>
Settings
</NavLink>
</div>
</div>
</div>
)
}
128 changes: 128 additions & 0 deletions web/components/forms.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { JSX } from 'preact'
import { useId } from 'preact/hooks'
import { A, LinkProps } from '../lib/router.tsx'

// Card component
export const Card = (
{ children, title, description }: {
title: string
description?: string
children: JSX.Element | JSX.Element[]
},
) => (
<div class='bg-surface rounded-lg border border-divider shadow-sm bg-base-100'>
<div class='p-4 sm:p-6'>
<h3 class='text-lg font-semibold text-text'>{title}</h3>
{description && (
<p class='mt-1 text-sm text-text-secondary'>{description}</p>
)}
</div>
<div class='p-4 sm:p-6 border-t border-divider'>
{children}
</div>
</div>
)

// Input component
export const Input = (
{ label, name, note, ...props }:
& { label: string; name: string; note?: string }
& JSX.InputHTMLAttributes<HTMLInputElement>,
) => {
const id = useId()
return (
<div>
<label for={id} class='block text-sm font-medium text-text-secondary'>
{label}
</label>
<input
id={id}
name={name}
class='mt-1 block w-full px-3 py-2 bg-bg border border-divider rounded-md shadow-sm placeholder-text-disabled focus:outline-none focus:ring-primary focus:border-primary sm:text-sm'
{...props}
/>
{note && <p class='mt-2 text-sm text-text-secondary'>{note}</p>}
</div>
)
}

// Button component
export const Button = (
{ children, variant = 'primary', ...props }:
& {
variant?: 'primary' | 'secondary' | 'danger'
}
& Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, 'class' | 'style'>
& Partial<LinkProps>,
) => {
const baseClasses =
'inline-flex justify-center py-2 px-4 border shadow-sm text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2'

const variants = {
primary:
'border-transparent text-white bg-primary hover:bg-primary-hover focus:ring-primary',
secondary:
'border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-indigo-500',
danger:
'border-transparent text-white bg-red-600 hover:bg-red-700 focus:ring-red-500',
}

if (props.href || props.hash || props.params) {
return (
<A
class={[baseClasses, variants[variant]].join(' ')}
{...props}
>
{children}
</A>
)
}

return (
<button
class={[baseClasses, variants[variant]].join(' ')}
{...props}
>
{children}
</button>
)
}

// Switch component
export const Switch = (
{ label, note, ...props }: {
label: string
note?: string
// checked: boolean
} & JSX.InputHTMLAttributes<HTMLInputElement>,
) => {
const id = useId()
return (
<div class='flex items-center justify-between'>
<span class='flex-grow flex flex-col'>
<label for={id} class='text-sm font-medium text-text-secondary'>
{label}
</label>
{note && <p class='text-sm text-text-tertiary'>{note}</p>}
</span>
<div class='relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in'>
<input
type='checkbox'
id={id}
class='absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer checked:right-0 checked:border-green-400'
{...props}
/>
<label
for={id}
class='block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer'
>
</label>
</div>
</div>
)
}

// Note component
export const Note = ({ children }: { children: string }) => (
<p class='mt-2 text-sm text-text-secondary'>{children}</p>
)
Loading
Loading