diff --git a/api/schema.ts b/api/schema.ts index 2712252..8071891 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -1,4 +1,4 @@ -import { OBJ, optional, STR } from './lib/validator.ts' +import { BOOL, OBJ, optional, STR } from './lib/validator.ts' import { Asserted } from './lib/router.ts' import { createCollection } from './lib/json_store.ts' @@ -6,6 +6,7 @@ export const userDef = OBJ({ userEmail: STR('The user email address'), userFullName: STR('The user login name'), userPicture: optional(STR('The user profile picture URL')), + isAdmin: BOOL('Is the user an admin?'), }) export const User = await createCollection< diff --git a/api/user.ts b/api/user.ts index 04babed..62dd89c 100644 --- a/api/user.ts +++ b/api/user.ts @@ -60,7 +60,7 @@ export async function authenticateOauthUser( let userEmail: string if (!existingUser) { - const newUser = await User.insert(oauthInfo) + const newUser = await User.insert({ ...oauthInfo, isAdmin: false }) userEmail = newUser.userEmail } else { userEmail = existingUser.userEmail diff --git a/deno.json b/deno.json index 3479991..16e7fc6 100644 --- a/deno.json +++ b/deno.json @@ -21,7 +21,8 @@ "docker:rm": "docker rm -f devtools-app", "docker:logs": "docker logs -f devtools-app", "docker:exec": "docker exec -it devtools-app /bin/sh", - "docker:clean": "docker rm -f devtools-app && docker rmi devtools" + "docker:clean": "docker rm -f devtools-app && docker rmi devtools", + "env:dev": "deno run -A tasks/env.ts" }, "imports": { "./": "./", diff --git a/tasks/env.ts b/tasks/env.ts new file mode 100644 index 0000000..fb09dce --- /dev/null +++ b/tasks/env.ts @@ -0,0 +1,8 @@ +import { decrypt } from 'https://gistcdn.githack.com/kigiri/21df06d173fcdced5281b86ba6ac1382/raw/crypto.js' + +const env = await decrypt( + '45641083e50bf3bc5b65dd16c16f7455367074d2c8a3ea16845704a7be6f457bde06b40740ae3456874486092d447eeae44341f5f2f53f9ed974d8182709c53a315a7942eb9699d993159aa2710de5e3eb1eaa780c832ad61c7e95e832bbfdf2ea704904c815e45ed901464ef680456f8ca7cdf561d7c4a100dad7d427383fa8ebb125f58ef4ad9c23029bcfd7a86a712dcc19ceec98e0c513cd297d43c547561f012c823790712391a5c186d9f2e52e971e2a71f4920331a00ea5532b3a6b28280c0b955fc90647dd48591ed9f782dac9fcead5709dbc0c27de142de663998040cb862f', + localStorage.password || (localStorage.password = prompt('password')), +) + +await Deno.writeTextFile('./.env.dev', env) diff --git a/web/components/Dialog.tsx b/web/components/Dialog.tsx new file mode 100644 index 0000000..f048f86 --- /dev/null +++ b/web/components/Dialog.tsx @@ -0,0 +1,63 @@ +import type { JSX } from 'preact' +import { useState } from 'preact/hooks' + +import { navigate, url } from '../lib/router.tsx' + +type DialogProps = { + id: string + children: preact.ComponentChildren +} & JSX.HTMLAttributes + +export const Dialog = ({ + id, + onClick, + onClose, + ...props +}: DialogProps) => { + const [dialogElem, setRef] = useState(null) + + const isOpen = url.params.dialog === id + if (dialogElem) { + if (!isOpen && dialogElem.open) { + dialogElem.close() + } else if (isOpen && !dialogElem.open) { + dialogElem.showModal() + } + } + + return ( + { + dialogElem === event.target && dialogElem?.close() + typeof onClick === 'function' && onClick(event) + }} + onClose={(event) => { + typeof onClose === 'function' && onClose(event) + if (!isOpen) return + navigate({ params: { dialog: null } }) + }} + ref={setRef} + > + + ) +} + +export const DialogModal = ({ children, ...props }: DialogProps) => { + return ( + + + + ) +} diff --git a/web/index.tsx b/web/index.tsx index 97cd8fa..e92a902 100644 --- a/web/index.tsx +++ b/web/index.tsx @@ -1,5 +1,6 @@ import { render } from 'preact' import { LoginPage } from './pages/LoginPage.tsx' +import { ProjectsPage } from './pages/ProjectsPage.tsx' import { BackgroundPattern } from './components/BackgroundPattern.tsx' import { Header } from './layout.tsx' import { user } from './lib/session.ts' @@ -9,13 +10,7 @@ const renderPage = () => { if (!user.data) { return } - return ( -
-

- Welcome to the Dev Tools App! -

-
- ) + return } const App = () => { return ( diff --git a/web/pages/ProjectsPage.tsx b/web/pages/ProjectsPage.tsx new file mode 100644 index 0000000..681763c --- /dev/null +++ b/web/pages/ProjectsPage.tsx @@ -0,0 +1,719 @@ +import { signal, useSignal } from '@preact/signals' +import { A, navigate } from '../lib/router.tsx' +import { + AlertTriangle, + ArrowRight, + Calendar, + Folder, + LucideIcon, + Plus, + Search, + Settings, +} from 'lucide-preact' +import { Dialog, DialogModal } from '../components/Dialog.tsx' +import { url } from '../lib/router.tsx' +import { JSX } from 'preact' +import { user } from '../lib/session.ts' + +type User = { + id: number + name: string + email: string + isAdmin: boolean + teamIds: number[] +} +type Team = { id: number; name: string } +type Project = { + slug: string + name: string + teamId: number + createdAt: string +} + +const users = signal([ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + isAdmin: true, + teamIds: [1333, 1334], + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + isAdmin: false, + teamIds: [1333], + }, + { + id: 3, + name: 'Cara', + email: 'cara@example.com', + isAdmin: false, + teamIds: [1334], + }, +]) +const teams = signal([ + { id: 1333, name: 'Platform' }, + { id: 1334, name: 'Tournament' }, + { id: 1335, name: 'Gamma' }, +]) +const projects = signal([ + { + slug: 'tomorrow-school', + name: 'Tomorrow School', + teamId: 1333, + createdAt: '2023-10-15T00:00:00.000Z', + }, + { + slug: 'tournament-beta', + name: 'Tournament Beta', + teamId: 1334, + createdAt: '2023-11-05T00:00:00.000Z', + }, +]) +const toastSignal = signal<{ message: string; type: 'info' | 'error' } | null>( + null, +) + +const slugify = (str: string) => + str.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '') + +function saveProject(data: { name: string; teamId: number; slug?: string }) { + const { slug, name, teamId } = data + const projectsValues = projects.peek() + if (!slug) { + const base = slugify(name) + let suffix = '' + let finalSlug = base + do { + finalSlug = base + suffix + suffix = suffix ? String(Number(suffix) + 1) : '0' + } while (projectsValues.some((p) => p.slug === finalSlug)) + const now = new Date().toISOString() + const project: Project = { slug: finalSlug, name, teamId, createdAt: now } + projects.value = [...projectsValues, project] + } else { + const idx = projectsValues.findIndex((p) => p.slug === slug) + const copy = [...projectsValues] + copy[idx] = { ...projectsValues[idx], name, teamId } + projects.value = copy + } + + navigate({ params: { dialog: null }, replace: true }) +} + +function saveTeam(data: { id?: number; name: string }) { + const { id, name } = data + if (id) { + const idx = teams.value.findIndex((t) => t.id === id) + if (idx !== -1) teams.value[idx] = { ...teams.value[idx], name } + } else { + const newId = Math.max(...teams.value.map((t) => t.id), 0) + 1 + teams.value = [...teams.value, { id: newId, name }] + } +} + +function toast(message: string, type: 'info' | 'error' = 'info') { + toastSignal.value = { message, type } + setTimeout(() => (toastSignal.value = null), 3000) +} + +function _deleteTeam(id: number) { + if (projects.value.some((p) => p.teamId === id)) { + toast('Cannot delete a team that still has projects.', 'error') + return + } + teams.value = teams.value.filter((t) => t.id !== id) + toast('Team deleted.') +} + +function addUserToTeam(userId: number, teamId: number) { + const user = users.value.find((u) => u.id === userId) + if (!user) return + if (!user.teamIds.includes(teamId)) { + user.teamIds = [...user.teamIds, teamId] + users.value = [...users.value] + } +} + +function removeUserFromTeam(userId: number, teamId: number) { + const user = users.value.find((u) => u.id === userId) + if (!user) return + user.teamIds = user.teamIds.filter((id) => id !== teamId) + users.value = [...users.value] +} + +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[] }, +) => ( + +) +const SectionTitle = ({ title, count }: { title: string; count: number }) => ( +
+

{title}

+ + {count} + +
+) + +const EmptyState = ( + { icon: Icon, title, subtitle }: { + icon: LucideIcon + title: string + subtitle?: string + }, +) => ( +
+ +

{title}

+ {subtitle &&

{subtitle}

} +
+) + +const ProjectCard = ({ project }: { project: Project }) => ( + +
+
+
+

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

+
+ {project.slug} +
+ + + {new Date(project.createdAt).toLocaleDateString()} + +
+
+
+ +
+
+
+) + +const Toast = () => { + if (!toastSignal.value) return null + return ( +
+ {toastSignal.value.type === 'error' && ( + + )} + {toastSignal.value.message} +
+ ) +} + +const TeamMembersRow = ({ user, team }: { user: User; team: Team }) => ( + + +
{user.name}
+
{user.email}
+ + {user.isAdmin ? 'Admin' : 'Member'} + + { + if ((e.target as HTMLInputElement).checked) { + addUserToTeam(user.id, team.id) + } else removeUserFromTeam(user.id, team.id) + }} + /> + + +) + +const TeamProjectsRow = ({ project }: { project: Project }) => ( + + {project.name} + {project.slug} + + {new Date(project.createdAt).toLocaleDateString()} + + + + Edit + + + Delete + + + +) + +const DialogSectionTitle = (props: JSX.HTMLAttributes) => ( +

+) + +const DialogTitle = (props: JSX.HTMLAttributes) => ( + <> +
+ +
+

+ +) + +const onSubmit = (e: Event) => { + e.preventDefault() + const { dialog, slug } = url.params + const isEdit = dialog === 'edit-project' + const project = isEdit + ? projects.value.find((p) => p.slug === slug) + : undefined + const form = e.currentTarget as HTMLFormElement + const name = (form.elements.namedItem('name') as HTMLInputElement).value + const teamId = (form.elements.namedItem('teamId') as HTMLSelectElement).value + if (!teamId || !name) return + saveProject({ + name, + teamId: Number(teamId), + slug: isEdit ? project?.slug : undefined, + }) +} + +function ProjectDialog() { + const { dialog, slug } = url.params + const isEdit = dialog === 'edit-project' + const project = isEdit + ? projects.value.find((p) => p.slug === slug) + : undefined + + return ( + + {isEdit ? 'Edit Project' : 'Add Project'} +
+ + + + + + + +
+
+ ) +} + +function TeamSettingsSection({ team }: { team: Team }) { + return ( +
+
+ + + + +
+
+
+

Danger Zone

+ + Delete Team + +
+
+ ) +} + +function TeamMembersSection({ team }: { team: Team }) { + return ( +
+ + + + + + + + + + {users.value.map((u) => ( + + ))} + +
UserRoleAction
+
+ ) +} + +function TeamProjectsSection({ team }: { team: Team }) { + const teamProjects = projects.value.filter((p) => p.teamId === team.id) + return ( +
+ + + Add Project + + {teamProjects.length === 0 + ?

No projects in this team.

+ : ( +
+ + + + + + + + + + + {teamProjects.map((p) => ( + + ))} + +
ProjectSlugCreated + Actions +
+
+ )} +
+ ) +} + +const submitTeam = (e: Event) => { + e.preventDefault() + const form = e.currentTarget as HTMLFormElement + const name = (form.elements.namedItem('name') as HTMLInputElement).value + if (!name.trim()) return + saveTeam({ name }) + form.reset() +} + +const TabNav = ({ tab, sections }: { tab: string; sections: string[] }) => ( +
+ {sections.map((t) => ( + + {t} + + ))} +
+) + +function TeamsManagementDialog() { + const { dialog, steamid, tab } = url.params + if (dialog !== 'manage-teams' || !steamid) return null + if (tab !== 'members' && tab !== 'projects' && tab !== 'settings') { + navigate({ params: { tab: 'members' }, replace: true }) + return null + } + + const selectedTeamId = Number(steamid) || teams.value[0]?.id + const selectedTeam = teams.value + .find((t) => t.id === selectedTeamId) + + return ( + + + + ) +} + +const onDelete = (e: Event) => { + e.preventDefault() +} +function DeleteDialog() { + const { dialog, id, key } = url.params + if (dialog !== 'delete' || (key !== 'project' && key !== 'team')) return null + + const name = key === 'project' + ? projects.value.find((p) => p.slug === id)?.name + : teams.value.find((t) => t.id === Number(id))?.name + + if (!name) return null + const confirmSlug = useSignal('') + const canDelete = confirmSlug.value === id + + return ( + + Confirm deletion +

+ Are you sure you want to delete{' '} + "{name}"? This action cannot be undone. +

+
+ + + confirmSlug.value = (e.target as HTMLInputElement).value.trim()} + placeholder={id || undefined} + /> + + +
+
+ ) +} + +export function ProjectsPage() { + const q = url.params.q?.toLowerCase() ?? '' + + const onSearchInput = (e: Event) => + navigate({ + params: { q: (e.target as HTMLInputElement).value || null }, + replace: true, + }) + + const filteredProjects = q + ? projects.value.filter((p) => + p.name.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q) + ) + : projects.value + + const projectsByTeam = filteredProjects.reduce((acc, p) => { + ;(acc[p.teamId] ||= []).push(p) + return acc + }, {} as Record) + + const isAdmin = user.data?.isAdmin ?? false + + const disable = isAdmin + ? '' + : 'pointer-events-none cursor-not-allowed opacity-20' + + return ( + + +

Projects

+ +
+ + + {teams.value.length === 0 + ? ( + + ) + : teams.value.map((team) => { + const teamProjects = projectsByTeam[team.id] ?? [] + return ( +
+ + {teamProjects.length > 0 + ? ( +
+ {teamProjects.map((p) => ( + + ))} +
+ ) + : ( +
+ +

+ {q + ? 'No projects found for this team' + : 'No projects yet'} +

+
+ )} +
+ ) + })} +
+ + + + +
+ ) +}