diff --git a/api/routes.ts b/api/routes.ts index 961511d..3f142a6 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -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')), @@ -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')), @@ -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', }), diff --git a/api/schema.ts b/api/schema.ts index 99ea860..e03a00d 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -21,14 +21,24 @@ export const TeamDef = OBJ({ export type Team = Asserted & 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 & 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 & BaseRecord + export const UsersCollection = await createCollection( { name: 'users', primaryKey: 'userEmail' }, ) @@ -39,7 +49,14 @@ export const TeamsCollection = await createCollection( 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' }, ) diff --git a/tasks/seed.ts b/tasks/seed.ts index ded8601..2c4abe2 100644 --- a/tasks/seed.ts +++ b/tasks/seed.ts @@ -41,22 +41,22 @@ const teams: Team[] = [ const projects: Omit[] = [ { - 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', diff --git a/web/components/Layout.tsx b/web/components/Layout.tsx new file mode 100644 index 0000000..42cb47c --- /dev/null +++ b/web/components/Layout.tsx @@ -0,0 +1,45 @@ +import { JSX } from 'preact' +import { SideBar } from './SideBar.tsx' + +export const PageLayoutWithSideBar = ( + { children }: { children: JSX.Element | JSX.Element[] | null }, +) => ( +
+ +
+ {children} +
+
+) + +export const PageLayout = ( + { children }: { children: JSX.Element | JSX.Element[] }, +) => ( +
+
+ {children} +
+
+) + +export const PageHeader = ( + { children, className }: { + className?: string + children: JSX.Element | JSX.Element[] + }, +) => ( +
+
+ {children} +
+
+) + +export const PageContent = ( + { children }: { children: JSX.Element | JSX.Element[] }, +) => ( +
{children}
+) diff --git a/web/components/SideBar.tsx b/web/components/SideBar.tsx new file mode 100644 index 0000000..d389f11 --- /dev/null +++ b/web/components/SideBar.tsx @@ -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 + }, +) => ( + + + {url.params.sidebar_collapsed !== 'true' && {children}} + +) + +export const SideBar = () => { + const { nav, sidebar_collapsed } = url.params + const isCollapsed = sidebar_collapsed === 'true' + return ( +
+
+
+ {!isCollapsed &&

Project

} + + {isCollapsed ? : } + +
+ +
+ + Settings + +
+
+
+ ) +} diff --git a/web/components/forms.tsx b/web/components/forms.tsx new file mode 100644 index 0000000..8d6fd90 --- /dev/null +++ b/web/components/forms.tsx @@ -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[] + }, +) => ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ {children} +
+
+) + +// Input component +export const Input = ( + { label, name, note, ...props }: + & { label: string; name: string; note?: string } + & JSX.InputHTMLAttributes, +) => { + const id = useId() + return ( +
+ + + {note &&

{note}

} +
+ ) +} + +// Button component +export const Button = ( + { children, variant = 'primary', ...props }: + & { + variant?: 'primary' | 'secondary' | 'danger' + } + & Omit, 'class' | 'style'> + & Partial, +) => { + 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 ( + + {children} + + ) + } + + return ( + + ) +} + +// Switch component +export const Switch = ( + { label, note, ...props }: { + label: string + note?: string + // checked: boolean + } & JSX.InputHTMLAttributes, +) => { + const id = useId() + return ( +
+ + + {note &&

{note}

} +
+
+ + +
+
+ ) +} + +// Note component +export const Note = ({ children }: { children: string }) => ( +

{children}

+) diff --git a/web/index.tsx b/web/index.tsx index e92a902..7634b3c 100644 --- a/web/index.tsx +++ b/web/index.tsx @@ -4,12 +4,17 @@ import { ProjectsPage } from './pages/ProjectsPage.tsx' import { BackgroundPattern } from './components/BackgroundPattern.tsx' import { Header } from './layout.tsx' import { user } from './lib/session.ts' +import { url } from './lib/router.tsx' +import { ProjectPage } from './pages/ProjectPage.tsx' const renderPage = () => { if (user.pending) return if (!user.data) { return } + if (url.path.startsWith('/projects/')) { + return + } return } const App = () => { diff --git a/web/pages/ProjectPage.tsx b/web/pages/ProjectPage.tsx new file mode 100644 index 0000000..ca4e66a --- /dev/null +++ b/web/pages/ProjectPage.tsx @@ -0,0 +1,91 @@ +import { A, navigate, url } from '../lib/router.tsx' +import { + PageContent, + PageHeader, + PageLayoutWithSideBar, +} from '../components/Layout.tsx' +import { DeploymentPage } from './project/DeploymentPage.tsx' +import { TasksPage } from './project/TaskPage.tsx' +import { SettingsPage } from './project/SettingsPage.tsx' +import { api } from '../lib/api.ts' +import { effect } from '@preact/signals' +import { Deployment } from '../../api/schema.ts' + +const pageMap = { + deployment: DeploymentPage, + tasks: TasksPage, + settings: SettingsPage, +} + +export const deployments: Deployment[] = [ + { + projectId: 'my-awesome-project', + url: 'https://my-app.fly.dev', + logsEnabled: true, + databaseEnabled: false, + sqlEndpoint: undefined, + sqlToken: undefined, + }, + { + projectId: 'my-awesome-project', + url: 'https://staging.my-app.fly.dev', + logsEnabled: false, + databaseEnabled: true, + sqlEndpoint: 'https://db.my-app.com/sql', + sqlToken: 'super-secret-token', + }, +] + +const project = api['GET/api/project'].signal() + +effect(() => { + const path = url.path + const projectSlug = path.split('/')[2] + if (projectSlug) { + project.fetch({ slug: projectSlug }) + } +}) + +export function ProjectPage() { + const { nav } = url.params + + const Component = pageMap[nav as keyof typeof pageMap] + if (!Component) { + navigate({ params: { nav: 'deployment' } }) + return null + } + + if (project.pending) { + return ( + +
Loading...
+
+ ) + } + + if (!project.data) { + return ( + <> + +

+ Deployments +

+
+ +
+

+ Please select a project to view deployments. +

+ Go to Projects +
+
+ + ) + } + + return ( + + + + ) +} diff --git a/web/pages/ProjectsPage.tsx b/web/pages/ProjectsPage.tsx index f1b71fb..5027c93 100644 --- a/web/pages/ProjectsPage.tsx +++ b/web/pages/ProjectsPage.tsx @@ -16,7 +16,8 @@ import { url } from '../lib/router.tsx' import { JSX } from 'preact' import { user } from '../lib/session.ts' import { api } from '../lib/api.ts' -import { Project as ApiProject, Team, User } from '../../api/schema.ts' +import { PageContent, PageHeader, PageLayout } from '../components/Layout.tsx' +import type { Project as ApiProject, Team, User } from '../../api/schema.ts' type Project = ApiProject @@ -55,13 +56,13 @@ async function saveProject( do { finalSlug = base + suffix suffix = suffix ? String(Number(suffix) + 1) : '0' - } while (projects.data?.some((p) => p.projectSlug === finalSlug)) + } while (projects.data?.some((p) => p.slug === finalSlug)) slug = finalSlug } try { await fetcher.fetch({ - projectSlug: finalSlug, - projectName: name, + slug: finalSlug, + name, teamId, repositoryUrl, isPublic: isPublic ?? false, @@ -114,7 +115,7 @@ function toast(message: string, type: 'info' | 'error' = 'info') { async function deleteProject(slug: string) { try { - await api['DELETE/api/project'].fetch({ projectSlug: slug }) + await api['DELETE/api/project'].fetch({ slug: slug }) toast('Project deleted.', 'info') projects.fetch() navigate({ params: { dialog: null, id: null, key: null }, replace: true }) @@ -166,29 +167,6 @@ async function removeUserFromTeam(user: User, team: Team) { } } -const PageLayout = ( - { children }: { children: JSX.Element | JSX.Element[] }, -) => ( -
-
- {children} -
-
-) -const PageHeader = ( - { children }: { children: JSX.Element | JSX.Element[] }, -) => ( -
-
- {children} -
-
-) -const PageContent = ( - { children }: { children: JSX.Element | JSX.Element[] }, -) => ( -
{children}
-) const FormField = ( { label, children }: { label: string; children: JSX.Element | JSX.Element[] }, ) => ( @@ -226,8 +204,8 @@ const ProjectCard = ( const isMember = team.teamMembers.includes(user.data?.userEmail || '') return ( @@ -236,14 +214,14 @@ const ProjectCard = (

- {project.projectName.length > 25 - ? project.projectName.slice(0, 22) + '…' - : project.projectName} + {project.name.length > 25 + ? project.name.slice(0, 22) + '…' + : project.name}

- {project.projectSlug} + {project.slug}
@@ -300,20 +278,20 @@ const TeamMembersRow = ({ user, team }: { user: User; team: Team }) => ( const TeamProjectsRow = ({ project }: { project: ApiProject }) => ( - {project.projectName} - {project.projectSlug} + {project.name} + {project.slug} {project.createdAt && new Date(project.createdAt).toLocaleDateString()} Edit Delete @@ -344,7 +322,7 @@ function ProjectDialog() { const { dialog, slug } = url.params const isEdit = dialog === 'edit-project' const project = isEdit - ? projects.data?.find((p) => p.projectSlug === slug) + ? projects.data?.find((p) => p.slug === slug) : undefined const handleSubmit = (e: Event) => { @@ -375,7 +353,7 @@ function ProjectDialog() { @@ -508,7 +486,7 @@ function TeamProjectsSection({ team }: { team: Team }) { {teamProjects.map((p) => ( - + ))} @@ -634,7 +612,7 @@ function DeleteDialog() { } const name = key === 'project' - ? projects.data?.find((p) => p.projectSlug === id)?.projectName + ? projects.data?.find((p) => p.slug === id)?.name : teams.data?.find((t) => t.teamId === id)?.teamName if (!name) return null @@ -699,8 +677,8 @@ export function ProjectsPage() { const filteredProjects = q ? projects.data?.filter((p) => - p.projectName.toLowerCase().includes(q) || - p.projectSlug.toLowerCase().includes(q) + p.name.toLowerCase().includes(q) || + p.slug.toLowerCase().includes(q) ) : projects.data @@ -774,7 +752,7 @@ export function ProjectsPage() {
{teamProjects.map((p) => ( diff --git a/web/pages/project/DeploymentPage.tsx b/web/pages/project/DeploymentPage.tsx new file mode 100644 index 0000000..64c3427 --- /dev/null +++ b/web/pages/project/DeploymentPage.tsx @@ -0,0 +1,180 @@ +import { PageContent, PageHeader } from '../../components/Layout.tsx' +import { url } from '../../lib/router.tsx' + +import { A, navigate } from '../../lib/router.tsx' +import { Calendar, Database, Logs, Search } from 'lucide-preact' +import { Deployment, Project } from '../../../api/schema.ts' +import { deployments } from '../ProjectPage.tsx' + +const DeploymentCard = ({ dep }: { dep: Deployment }) => { + const created = dep.createdAt ? new Date(dep.createdAt) : null + const formattedDate = created ? created.toLocaleString() : 'Unknown' + + return ( + + ) +} + +const LogsSection = () => { + return ( +
+

Logs

+

Log details for the selected deployment will go here.

+
+ ) +} + +const DatabaseSection = () => { + return ( +
+

Database

+

Database details for the selected deployment will go here.

+
+ ) +} + +export const DeploymentPage = ({}: { project: Project }) => { + const { deployment, deptab } = url.params + + const selectedDeployment = deployment + ? deployments.find((d) => d.url === deployment) + : null + + if ( + selectedDeployment && deptab === 'logs' && !selectedDeployment.logsEnabled + ) { + navigate({ + params: { + deptab: selectedDeployment.databaseEnabled ? 'database' : null, + }, + }) + } + + if ( + selectedDeployment && deptab === 'database' && + !selectedDeployment.databaseEnabled + ) { + navigate({ + params: { deptab: selectedDeployment.logsEnabled ? 'logs' : null }, + }) + } + + const tab = deptab === 'logs' ? : + return ( + <> + + +
+
+ +
+ +
+ + +
+
+
+ + {selectedDeployment + ? tab + : ( +
+ {deployments.map((dep) => ( + + ))} +
+ )} +
+ + ) +} diff --git a/web/pages/project/SettingsPage.tsx b/web/pages/project/SettingsPage.tsx new file mode 100644 index 0000000..8cf3d83 --- /dev/null +++ b/web/pages/project/SettingsPage.tsx @@ -0,0 +1,293 @@ +import { Deployment, Project, User } from '../../../api/schema.ts' +import { PageContent, PageHeader } from '../../components/Layout.tsx' +import { Button, Card, Input, Switch } from '../../components/forms.tsx' +import { useSignal } from '@preact/signals' +import { navigate, url } from '../../lib/router.tsx' +import { JSX } from 'preact' +import { api } from '../../lib/api.ts' +import { deployments } from '../ProjectPage.tsx' +import { user } from '../../lib/session.ts' + +const users = api['GET/api/users'].signal() +users.fetch() + +const team = api['GET/api/team'].signal() + +function ProjectInfoForm({ project }: { project: Project }) { + const handleSubmit = (e: JSX.TargetedEvent) => { + e.preventDefault() + } + + return ( + +
+
+ + +
+
+ +
+
+
+ ) +} + +function DeploymentForm({ deployment }: { deployment?: Deployment }) { + const databaseEnabled = useSignal(deployment?.databaseEnabled || false) + const handleSubmit = (e: JSX.TargetedEvent) => { + e.preventDefault() + } + + return ( + +
+
+ + + databaseEnabled.value = e.currentTarget.checked} + note='Provide an endpoint to execute SQL queries against your database.' + /> + {databaseEnabled.value && ( +
+ + +
+ )} +
+
+ + +
+
+
+ ) +} + +function DeploymentsList({ deployments }: { deployments: Deployment[] }) { + const handleDelete = (_id: string) => { + } + + return ( + +
+ {deployments.map((dep) => ( +
+
+

{dep.url}

+

+ Logs: {dep.logsEnabled ? 'Enabled' : 'Disabled'} | Database: + {' '} + {dep.databaseEnabled ? 'Enabled' : 'Disabled'} +

+
+
+ + +
+
+ ))} +
+
+ +
+
+ ) +} + +function UserManagement() { + const teamMembersDetails = team.data?.teamMembers.map((email) => + users.data?.find((u) => u.userEmail === email) + ).filter(Boolean) as User[] + + const handleAddUser = (e: JSX.TargetedEvent) => { + e.preventDefault() + } + + const handleRemoveUser = (_email: string) => { + } + + return ( + +
+ {teamMembersDetails.map((member) => ( +
+
+ {member.userFullName} +
+

{member.userFullName}

+

+ {member.userEmail} +

+
+
+ +
+ ))} +
+
+

Add a new user

+
+ +
+ +
+
+
+
+ ) +} + +export const SettingsPage = ({ project }: { project: Project }) => { + if (!user.data?.isAdmin) { + navigate({ params: { nav: 'deployments' } }) + } + const { view = 'info', action, id } = url.params + + team.fetch({ teamId: project.teamId }) + + const content = view === 'deployments' + ? ( + action === 'add' + ? + : action === 'edit' && id + ? ( + d.url === id)} + /> + ) + : + ) + : view === 'users' + ? + : + + return ( + <> + +

+ Project Settings: {project.name} +

+
+ + + +
+
+ +
+ {content} +
+
+ + ) +} diff --git a/web/pages/project/TaskPage.tsx b/web/pages/project/TaskPage.tsx new file mode 100644 index 0000000..3017305 --- /dev/null +++ b/web/pages/project/TaskPage.tsx @@ -0,0 +1,17 @@ +import { Project } from '../../../api/schema.ts' +import { PageContent, PageHeader } from '../../components/Layout.tsx' + +export const TasksPage = ({ project }: { project: Project }) => { + return ( + <> + +

+ Project: {project.name} +

+
+ +

This is the project page for {project.name}.

+
+ + ) +}