From 2f35eba73a39343d9ce4b550572d339e27a22c13 Mon Sep 17 00:00:00 2001 From: Harley John Torrisi Date: Sun, 24 Dec 2023 01:40:29 +1000 Subject: [PATCH 1/7] Created initial Projects page. --- src/app/dashboard/AppMenu.tsx | 6 +-- src/app/dashboard/layout.tsx | 5 ++- src/app/dashboard/page.tsx | 3 +- .../projects/_components/ProjectList.tsx | 37 +++++++++++++++++++ src/app/dashboard/projects/page.tsx | 23 ++++++++++++ src/types/children.d.ts | 4 +- 6 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 src/app/dashboard/projects/_components/ProjectList.tsx create mode 100644 src/app/dashboard/projects/page.tsx diff --git a/src/app/dashboard/AppMenu.tsx b/src/app/dashboard/AppMenu.tsx index cc795a9..0fdb33c 100644 --- a/src/app/dashboard/AppMenu.tsx +++ b/src/app/dashboard/AppMenu.tsx @@ -12,9 +12,9 @@ import { useState } from 'react' const navLinks = [ { title: 'Dashboard', path: '/dashboard' }, - { title: 'Projects', path: '/projects' }, - { title: 'Posts', path: '/posts' }, - { title: 'Images', path: '/images' }, + { title: 'Projects', path: '/dashboard/projects' }, + { title: 'Posts', path: '/dashboard/posts' }, + { title: 'Images', path: '/dashboard/images' }, ] type MenuButtonProps = { title: string, path?: string, action?: () => void } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index c6cab93..8afea4b 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,14 +1,15 @@ import AppMenu from '@/app/dashboard/AppMenu' import { RequireSessionWrapper } from '@/modules/auth/RequireSessionWrapper' +import { Container } from '@mui/material' export default function Layout(props: ChildProps) { return ( -
+ {props.children} -
+
) } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index f0313f6..f539fd8 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,9 +1,10 @@ +import { Typography } from '@mui/material' export default function DashboardPage() { return ( <> -

Dashboard

+ Dashboard ) } diff --git a/src/app/dashboard/projects/_components/ProjectList.tsx b/src/app/dashboard/projects/_components/ProjectList.tsx new file mode 100644 index 0000000..8105f01 --- /dev/null +++ b/src/app/dashboard/projects/_components/ProjectList.tsx @@ -0,0 +1,37 @@ +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { Accordion, AccordionDetails, AccordionSummary, Typography } from '@mui/material' + +type Props = { + projects: ProjectListItem[] +} + +export function ProjectList(props: Props) +{ + const { projects } = props + + if (projects.length === 0) + { + return ( + + + No projects created yet... + + + ) + } + + return ( +
+ {projects.map((project) => ( + + }> + {project.name} + + + {/* Add details for the project */} + + + ))} +
+ ) +} diff --git a/src/app/dashboard/projects/page.tsx b/src/app/dashboard/projects/page.tsx new file mode 100644 index 0000000..8588c50 --- /dev/null +++ b/src/app/dashboard/projects/page.tsx @@ -0,0 +1,23 @@ +import { ProjectList } from '@/app/dashboard/projects/_components/ProjectList' +import { getDatabaseClientAsync } from '@/modules/database/databaseFactory' +import { Stack, Typography } from '@mui/material' + +const getProjectsAsync = async () => +{ + const client = await getDatabaseClientAsync() + const projects = await client.getProjectListAsync() + console.warn('projects', projects) + return projects +} + +export default async function ProjectsPage() +{ + const projects = await getProjectsAsync() + + return ( + + Projects + + + ) +} diff --git a/src/types/children.d.ts b/src/types/children.d.ts index 4f1d2b7..1f95d2a 100644 --- a/src/types/children.d.ts +++ b/src/types/children.d.ts @@ -1,10 +1,10 @@ export declare global { export type ChildProps = { - children?: React.ReactNode + children?: React.JSX } export type ChildPropsRequired = { - children: React.ReactNode + children: React.JSX } } From 8ae036538489e26fd0bc4ca6068048e6fe9ea7a2 Mon Sep 17 00:00:00 2001 From: Harley John Torrisi Date: Mon, 25 Dec 2023 01:19:33 +1000 Subject: [PATCH 2/7] Setup shell for Project edit. --- .vscode/extensions.json | 3 +- .vscode/settings.json | 3 +- bun.lockb | Bin 155644 -> 157249 bytes package.json | 2 + .../projects/_components/ProjectList.tsx | 54 ++++++++++++++--- src/modules/database/databaseClient.ts | 3 +- src/modules/database/models.ts | 5 +- .../20231224141234_migrate/migration.sql | 11 ++++ .../prismaCockroachDatabaseClient.ts | 57 +++++++++++++++++- .../vendors/prisma-cockroach/schema.prisma | 1 + 10 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 src/modules/database/vendors/prisma-cockroach/migrations/20231224141234_migrate/migration.sql diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 2c67ce6..fd6c338 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "streetsidesoftware.code-spell-checker", "github.vscode-github-actions", "wix.vscode-import-cost", - "prisma.prisma" + "prisma.prisma", + "gruntfuggly.todo-tree" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index dc13ec1..f185ea8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,8 @@ "bunx", "fontsource", "linq", - "NEXTAUTH" + "NEXTAUTH", + "preact" ], "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, diff --git a/bun.lockb b/bun.lockb index f82abaf762e06cb7ab88fcea2e395af48cb13f5f..c295ff6cd4c467c4c98a7d81b145d75da8d831a1 100755 GIT binary patch delta 30200 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≪L#04M;aqHzX@cL6Tq0kC!;@EqUR3 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 g8cTh|P}Sjz_;jPsRdKM=1|NNczOh @@ -20,15 +26,49 @@ export function ProjectList(props: Props) ) } + function isSameProject(project1: ProjectDetail | null, project2: ProjectDetail | null) + { + return project1?.id === project2?.id + } + + async function updateActiveProject() + { + console.log('Update Project') + isSaving.value = true + } + return (
- {projects.map((project) => ( - + {projects.value.map((project) => ( + }> {project.name} - {/* Add details for the project */} + { activeProject.value!.name = e.currentTarget.value } + } : {})} + /> + { activeProject.value!.active = (e.target as HTMLInputElement).checked } + } : {})} + control={} + /> + ))} diff --git a/src/modules/database/databaseClient.ts b/src/modules/database/databaseClient.ts index c125983..f0a35be 100644 --- a/src/modules/database/databaseClient.ts +++ b/src/modules/database/databaseClient.ts @@ -1,4 +1,5 @@ export interface DatabaseClient { - getProjectListAsync(): Promise; + getProjectListAsync(): Promise; + createProjectAsync(name: string): Promise; } diff --git a/src/modules/database/models.ts b/src/modules/database/models.ts index 1ce75b7..83431c3 100644 --- a/src/modules/database/models.ts +++ b/src/modules/database/models.ts @@ -2,6 +2,7 @@ type Project = { id: string; name: string; active: boolean; + meta: Record; posts: Post[]; accessTokens: AccessToken[]; }; @@ -29,4 +30,6 @@ type Post = { type PostStatus = 'ACTIVE' | 'DISABLED' | 'HIDDEN' -type ProjectListItem = Pick; +type ProjectDetail = Pick & { + accessTokens: Pick[]; +}; diff --git a/src/modules/database/vendors/prisma-cockroach/migrations/20231224141234_migrate/migration.sql b/src/modules/database/vendors/prisma-cockroach/migrations/20231224141234_migrate/migration.sql new file mode 100644 index 0000000..b0d0408 --- /dev/null +++ b/src/modules/database/vendors/prisma-cockroach/migrations/20231224141234_migrate/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `meta` to the `Project` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "AccessToken" ALTER COLUMN "token" SET DEFAULT md5(random()::text); + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "meta" JSONB NOT NULL; diff --git a/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts b/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts index 0352040..3d0990c 100644 --- a/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts +++ b/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts @@ -1,5 +1,5 @@ import { DatabaseClient } from '@/modules/database/databaseClient' -import { PrismaClient } from '@prisma/client' +import { Prisma, PrismaClient } from '@prisma/client' export class PrismaCockroachDatabaseClient implements DatabaseClient { @@ -10,16 +10,67 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient this.prisma = new PrismaClient() } - async getProjectListAsync(): Promise + private getPrismaJsonValue(jsonValue: Prisma.JsonValue) + { + return (jsonValue?.valueOf() ?? {}) as T + } + + async getProjectListAsync(): Promise { const projects = await this.prisma.project.findMany({ select: { id: true, name: true, active: true, + meta: true, + accessTokens: { + select: { + id: true, + token: true + } + } + } + }) + + return projects.map(x => ({...x, meta: this.getPrismaJsonValue(x.meta)})) + } + + async createProjectAsync(name: string): Promise + { + const matchingProjectNameCount = await this.prisma.project.count({ + where: { + name + } + }) + + if (matchingProjectNameCount > 0) + { + throw new Error(`Project with name "${name}" already exists.`) + } + + const project = await this.prisma.project.create({ + data: { + name, + active: true, + meta: {}, + accessTokens: { + create: {} + } + }, + select: { + id: true, + name: true, + active: true, + meta: true, + accessTokens: { + select: { + id: true, + token: true + } + } } }) - return projects + return {...project, meta: this.getPrismaJsonValue(project.meta)} } } diff --git a/src/modules/database/vendors/prisma-cockroach/schema.prisma b/src/modules/database/vendors/prisma-cockroach/schema.prisma index 843ee53..8e3c529 100644 --- a/src/modules/database/vendors/prisma-cockroach/schema.prisma +++ b/src/modules/database/vendors/prisma-cockroach/schema.prisma @@ -11,6 +11,7 @@ model Project { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String @db.String() active Boolean @db.Bool + meta Json @db.JsonB // Relational posts Post[] accessTokens AccessToken[] From ef7ba4540771b421b2a025400937812d71cb8bd6 Mon Sep 17 00:00:00 2001 From: Harley John Torrisi Date: Sun, 17 Mar 2024 13:06:31 +1000 Subject: [PATCH 3/7] Setup functionality for editing most aspects of project. --- bun.lockb | Bin 157249 -> 155718 bytes next.config.js | 5 + package.json | 75 +++--- src/app/(admin)/layout.tsx | 10 - src/app/(admin)/nextauth/page.tsx | 23 -- .../dashboard/_actions/accessTokenActions.ts | 17 ++ src/app/dashboard/_actions/projectActions.ts | 25 ++ .../_components/CreateProjectButton.tsx | 11 + .../projects/_components/ProjectList.tsx | 230 +++++++++++++----- .../projects/_components/ProjectListItem.tsx | 137 +++++++++++ src/app/dashboard/projects/page.tsx | 10 +- src/app/layout.tsx | 4 + src/components/confirmationModal.tsx | 101 ++++++++ src/components/loadingModal.tsx | 62 +++++ src/modules/custom-events/createEvent.ts | 43 ++++ .../custom-events/events/newProjectEvent.ts | 5 + src/modules/database/databaseClient.ts | 13 +- src/modules/database/models.ts | 20 +- src/modules/database/requestTypes.ts | 5 + src/modules/database/responseTypes.ts | 7 + .../prismaCockroachDatabaseClient.ts | 108 +++++--- src/modules/utility/mapRecord.ts | 4 + tsconfig.json | 4 +- 23 files changed, 745 insertions(+), 174 deletions(-) delete mode 100644 src/app/(admin)/layout.tsx delete mode 100644 src/app/(admin)/nextauth/page.tsx create mode 100644 src/app/dashboard/_actions/accessTokenActions.ts create mode 100644 src/app/dashboard/_actions/projectActions.ts create mode 100644 src/app/dashboard/projects/_components/CreateProjectButton.tsx create mode 100644 src/app/dashboard/projects/_components/ProjectListItem.tsx create mode 100644 src/components/confirmationModal.tsx create mode 100644 src/components/loadingModal.tsx create mode 100644 src/modules/custom-events/createEvent.ts create mode 100644 src/modules/custom-events/events/newProjectEvent.ts create mode 100644 src/modules/database/requestTypes.ts create mode 100644 src/modules/database/responseTypes.ts create mode 100644 src/modules/utility/mapRecord.ts diff --git a/bun.lockb b/bun.lockb index c295ff6cd4c467c4c98a7d81b145d75da8d831a1..ddd17bd78bf8cb0c2e9fa17bcc0391ac6c433077 100755 GIT binary patch delta 31847 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 delta 33116 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 diff --git a/next.config.js b/next.config.js index 18c4ec3..8b0f5ce 100644 --- a/next.config.js +++ b/next.config.js @@ -8,6 +8,11 @@ const nextConfig = { sassOptions: { includePaths: [path.join(__dirname, 'src', 'styles')], }, + experimental: { + // TODO: Followup and make sure all security concerns are addressed. + // https://nextjs.org/docs/app/building-your-application/data-fetching/forms-and-mutations#convention + serverActions: true, + }, } module.exports = nextConfig diff --git a/package.json b/package.json index 77b20b4..3177983 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,40 @@ { - "name": "hobby-cms", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "dev:t": "next dev --turbo", - "build": "next build", - "start": "next start", - "lint": "next lint", - "lint:fix": "next lint --fix", - "db:prisma-cockroach:generate": "prisma generate --schema=src/modules/database/vendors/prisma-cockroach/schema.prisma", - "db:prisma-cockroach:migrate": "prisma migrate dev --name migrate --schema=src/modules/database/vendors/prisma-cockroach/schema.prisma" - }, - "dependencies": { - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "@fontsource/roboto": "^5.0.8", - "@mui/icons-material": "^5.14.13", - "@mui/material": "^5.14.14", - "@preact/signals-react": "^2.0.0", - "@prisma/client": "5.7.1", - "linq": "^4.0.2", - "next": "13.5.6", - "next-auth": "^4.24.3", - "react": "^18", - "react-dom": "^18", - "sass": "^1.69.5" - }, - "devDependencies": { - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "eslint": "^8", - "eslint-config-next": "13.5.6", - "typescript": "^5", - "prisma": "^5.7.1" - } + "name": "hobby-cms", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "dev:t": "next dev --turbo", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "db:prisma-cockroach:generate": "prisma generate --schema=src/modules/database/vendors/prisma-cockroach/schema.prisma", + "db:prisma-cockroach:migrate": "prisma migrate dev --name migrate --schema=src/modules/database/vendors/prisma-cockroach/schema.prisma" + }, + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@fontsource/roboto": "^5.0.8", + "@mui/icons-material": "^5.14.13", + "@mui/material": "^5.14.14", + "@prisma/client": "5.7.1", + "@types/uuid": "^9.0.8", + "linq": "^4.0.2", + "next": "13.5.6", + "next-auth": "^4.24.3", + "react": "^18", + "react-dom": "^18", + "sass": "^1.69.5", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "13.5.6", + "typescript": "^5", + "prisma": "^5.7.1" + } } diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx deleted file mode 100644 index 9f7de65..0000000 --- a/src/app/(admin)/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { RequireSessionWrapper } from '@/modules/auth/RequireSessionWrapper' - -export default function Layout(props: ChildProps) -{ - return ( - - {props.children} - - ) -} diff --git a/src/app/(admin)/nextauth/page.tsx b/src/app/(admin)/nextauth/page.tsx deleted file mode 100644 index 8aacdd5..0000000 --- a/src/app/(admin)/nextauth/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { LogoutButton } from '@/components/authDemo' -import Box from '@mui/material/Box' -import Card from '@mui/material/Card' -import Container from '@mui/material/Container' -import Typography from '@mui/material/Typography' - -export default function Home() -{ - return ( -
- - - - Hello World ~ Signed In - - - - - - -
- ) -} diff --git a/src/app/dashboard/_actions/accessTokenActions.ts b/src/app/dashboard/_actions/accessTokenActions.ts new file mode 100644 index 0000000..d93af03 --- /dev/null +++ b/src/app/dashboard/_actions/accessTokenActions.ts @@ -0,0 +1,17 @@ +'use server' + +import { getDatabaseClientAsync } from '@/modules/database/databaseFactory' +import { AccessTokenDetail } from '@/modules/database/responseTypes' + +export async function createProjectTokenServerAction(projectId: string): Promise +{ + const client = await getDatabaseClientAsync() + const token = await client.createAccessTokenAsync(projectId) + return token +} + +export async function deleteProjectTokenServerAction(tokenId: string): Promise +{ + const client = await getDatabaseClientAsync() + await client.deleteAccessTokenAsync(tokenId) +} diff --git a/src/app/dashboard/_actions/projectActions.ts b/src/app/dashboard/_actions/projectActions.ts new file mode 100644 index 0000000..654c4b2 --- /dev/null +++ b/src/app/dashboard/_actions/projectActions.ts @@ -0,0 +1,25 @@ +'use server' + +import { getDatabaseClientAsync } from '@/modules/database/databaseFactory' +import { ProjectUpdateValues } from '@/modules/database/requestTypes' +import { ProjectDetail } from '@/modules/database/responseTypes' + +export async function createProjectServerAction(projectName: string, isActive: boolean): Promise +{ + const client = await getDatabaseClientAsync() + const project = await client.createProjectAsync(projectName, isActive) + return project +} + +export async function deleteProjectServerAction(projectId: string): Promise +{ + const client = await getDatabaseClientAsync() + await client.deleteProjectAsync(projectId) +} + +export async function updateProjectServerAction(projectId: string, values: ProjectUpdateValues): Promise +{ + const client = await getDatabaseClientAsync() + const project = await client.updateProjectAsync(projectId, values) + return project +} diff --git a/src/app/dashboard/projects/_components/CreateProjectButton.tsx b/src/app/dashboard/projects/_components/CreateProjectButton.tsx new file mode 100644 index 0000000..44f708c --- /dev/null +++ b/src/app/dashboard/projects/_components/CreateProjectButton.tsx @@ -0,0 +1,11 @@ +'use client' + +import { newProjectEvent } from '@/modules/custom-events/events/newProjectEvent' +import { Button } from '@mui/material' + +export function CreateProjectButton() +{ + return ( + + ) +} diff --git a/src/app/dashboard/projects/_components/ProjectList.tsx b/src/app/dashboard/projects/_components/ProjectList.tsx index d521e9c..a9f559a 100644 --- a/src/app/dashboard/projects/_components/ProjectList.tsx +++ b/src/app/dashboard/projects/_components/ProjectList.tsx @@ -1,76 +1,194 @@ -'use client' +/** + * ProjectList generates a list of ProjectListItemComponents + * Only one project can be editable at a time; + * If the accordion is closed before saving, the changes are discarded. + * For the active project, instead of passing projects[index], we pass activeProject. + * activeProject is used to update projects[] when the user saves changes. + * Because only one project is updatable at a time, we don't need to reference projectId for most handlers. + */ -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import { Accordion, AccordionDetails, AccordionSummary, Button, FormControlLabel, Switch, TextField, Typography } from '@mui/material' -import { signal } from '@preact/signals-react' -import Enumerable from 'linq' +'use client' -type Props = { - projects: ProjectDetail[] -} +import { createProjectTokenServerAction, deleteProjectTokenServerAction } from '@/app/dashboard/_actions/accessTokenActions' +import { deleteProjectServerAction, updateProjectServerAction } from '@/app/dashboard/_actions/projectActions' +import { ProjectListItem } from '@/app/dashboard/projects/_components/ProjectListItem' +import { invokeConfirmationModal } from '@/components/confirmationModal' +import { invokeLoadingModal } from '@/components/loadingModal' +import { ProjectUpdateValues } from '@/modules/database/requestTypes' +import { ProjectDetail } from '@/modules/database/responseTypes' +import { useCallback, useMemo, useState } from 'react' -export function ProjectList(props: Props) +export function ProjectList(props: { projects: ProjectDetail[] }) { - const projects = signal(props.projects) - const activeProject = signal(Enumerable.from(props.projects).firstOrDefault() ?? null) - const isSaving = signal(false) + const [projects, setProjects] = useState(props.projects) + const [activeProject, setActiveProject] = useState(projects[0]) + + const hasActiveProjectDetailsChanged = useMemo(() => + { + if (!activeProject) return false + + const originalProject = projects.find(x => x.id === activeProject.id) + if (!originalProject) return false + + const details = (project: ProjectDetail) => JSON.stringify({ + name: project.name, + active: project.active, + meta: project.meta + }) + + return details(activeProject) !== details(originalProject) + }, [activeProject, projects]) + + const checkProjectIsActive = useCallback((projectId: string) => activeProject?.id === projectId, [activeProject]) + + function setActiveProjectHandler(projectId: string) + { + if (projectId === activeProject?.id) return + + const project = projects.find(x => x.id === projectId) + if (project) setActiveProject(project) + } - if (projects.value.length === 0) + function updateActiveProjectHandler(values: ProjectUpdateValues) { - return ( - - - No projects created yet... - - - ) + if (!activeProject) return + + const updatedProject = { ...activeProject, ...values } + setActiveProject(updatedProject) } - function isSameProject(project1: ProjectDetail | null, project2: ProjectDetail | null) + async function saveActiveProjectHandler() { - return project1?.id === project2?.id + if (!activeProject) return + + invokeConfirmationModal({ + description: 'Are you sure you want to save changes to this project?', + onConfirmed: (confirmed) => confirmed && save(), + }) + + const invokeLoading = (display: boolean) => invokeLoadingModal({ display, textOverride: 'Saving Project' }) + + const save = async () => + { + invokeLoading(true) + + const updatedProject = await updateProjectServerAction(activeProject.id, { + name: activeProject.name, + active: activeProject.active, + meta: activeProject.meta, + }) + + setProjects(projects.map(project => + project.id === activeProject.id + ? updatedProject + : project + )) + + invokeLoading(false) + } } - async function updateActiveProject() + async function deleteActiveProjectHandler() { - console.log('Update Project') - isSaving.value = true + if (!activeProject) return + + invokeConfirmationModal({ + description: 'Are you sure you want to delete this project?', + onConfirmed: (confirmed) => confirmed && save(), + }) + + const invokeLoading = (display: boolean) => invokeLoadingModal({ display, textOverride: 'Deleting Project' }) + + const save = async () => + { + invokeLoading(true) + + await deleteProjectServerAction(activeProject.id) + + const updatedProjectList = projects.filter(project => project.id !== activeProject.id) + + setProjects(updatedProjectList) + setActiveProject(updatedProjectList[0] ?? undefined) + + invokeLoading(false) + } + } + + async function createTokenHandler() + { + if (!activeProject) return + + invokeConfirmationModal({ + description: 'Are you sure you want to create new token?', + onConfirmed: (confirmed) => confirmed && save(), + }) + + const invokeLoading = (display: boolean) => invokeLoadingModal({ display, textOverride: 'Creating Token' }) + + const save = async () => + { + invokeLoading(true) + + const newToken = await createProjectTokenServerAction(activeProject.id) + + const updatedProjectList = projects.map(project => + project.id === activeProject.id + ? { ...project, accessTokens: [...project.accessTokens, newToken] } + : project + ) + + setProjects(updatedProjectList) + setActiveProject(updatedProjectList.find(project => project.id === activeProject.id) ?? undefined) + + invokeLoading(false) + } + } + + async function deleteTokenHandler(tokenId: string) + { + if (!activeProject) return + + invokeConfirmationModal({ + description: 'Are you sure you want to delete this token?', + onConfirmed: (confirmed) => confirmed && save(), + }) + + const invokeLoading = (display: boolean) => invokeLoadingModal({ display, textOverride: 'Deleting Token' }) + + const save = async () => + { + invokeLoading(true) + + await deleteProjectTokenServerAction(tokenId) + + const updatedProjectList = projects.map(project => + project.id === activeProject.id + ? { ...project, accessTokens: project.accessTokens.filter(x => x.id !== tokenId) } + : project + ) + + setProjects(updatedProjectList) + setActiveProject(updatedProjectList.find(project => project.id === activeProject.id) ?? undefined) + + invokeLoading(false) + } } return (
- {projects.value.map((project) => ( - ( + - }> - {project.name} - - - { activeProject.value!.name = e.currentTarget.value } - } : {})} - /> - { activeProject.value!.active = (e.target as HTMLInputElement).checked } - } : {})} - control={} - /> - - - + project={checkProjectIsActive(project.id) ? activeProject! : project} + expanded={checkProjectIsActive(project.id)} + detailsChangePending={checkProjectIsActive(project.id) && hasActiveProjectDetailsChanged} + updateDetail={updateActiveProjectHandler} + saveProject={saveActiveProjectHandler} + deleteProject={deleteActiveProjectHandler} + createToken={createTokenHandler} + deleteToken={deleteTokenHandler} + setActiveProject={setActiveProjectHandler} + /> ))}
) diff --git a/src/app/dashboard/projects/_components/ProjectListItem.tsx b/src/app/dashboard/projects/_components/ProjectListItem.tsx new file mode 100644 index 0000000..0a5a912 --- /dev/null +++ b/src/app/dashboard/projects/_components/ProjectListItem.tsx @@ -0,0 +1,137 @@ +'use client' + +import { ProjectUpdateValues } from '@/modules/database/requestTypes' +import { ProjectDetail } from '@/modules/database/responseTypes' +import +{ + ContentCopy as ContentCopyIcon, + Delete as DeleteIcon, + ExpandMore as ExpandMoreIcon, + Save as SaveIcon +} from '@mui/icons-material' +import { Accordion, AccordionActions, AccordionDetails, AccordionSummary, Box, Button, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography } from '@mui/material' + +type Props = { + project: ProjectDetail + expanded: boolean + detailsChangePending: boolean + updateDetail: (values: ProjectUpdateValues) => void + saveProject: () => void + deleteProject: () => void + createToken: () => void + deleteToken: (tokenId: string) => void + setActiveProject: (projectId: string) => void +} + +// TODO: Meta Component +// TODO: Mask token + +export function ProjectListItem(props: Props) +{ + const { + project, + expanded, + detailsChangePending, + updateDetail, + saveProject, + deleteProject, + createToken, + deleteToken, + setActiveProject + } = props + + return ( + setActiveProject(project.id)} + > + }> + {project.name} + + + + + updateDetail({ name: e.currentTarget.value })} + /> + + + + Project Status + + + + + + + + Tokens + + + + + + + + {project.accessTokens.map((token) => ( + + + button': { + visibility: 'visible' + } + }} + > + {token.token} + navigator.clipboard.writeText(token.token)} + > + + + + + + deleteToken(token.id)}> + + + + + ))} + +
+
+
+
+ + + + + + + + +
+ ) +} diff --git a/src/app/dashboard/projects/page.tsx b/src/app/dashboard/projects/page.tsx index 8588c50..2b0529d 100644 --- a/src/app/dashboard/projects/page.tsx +++ b/src/app/dashboard/projects/page.tsx @@ -1,3 +1,4 @@ +import { CreateProjectButton } from '@/app/dashboard/projects/_components/CreateProjectButton' import { ProjectList } from '@/app/dashboard/projects/_components/ProjectList' import { getDatabaseClientAsync } from '@/modules/database/databaseFactory' import { Stack, Typography } from '@mui/material' @@ -5,8 +6,7 @@ import { Stack, Typography } from '@mui/material' const getProjectsAsync = async () => { const client = await getDatabaseClientAsync() - const projects = await client.getProjectListAsync() - console.warn('projects', projects) + const projects = await client.getProjectsAsync() return projects } @@ -16,8 +16,12 @@ export default async function ProjectsPage() return ( - Projects + + Projects + + + {/* */} ) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5c17d7f..feb37af 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,8 @@ import '@fontsource/roboto/400.css' import '@fontsource/roboto/500.css' import '@fontsource/roboto/700.css' +import { ConfirmationModal } from '@/components/confirmationModal' +import LoadingModal from '@/components/loadingModal' import { NextAuthProvider } from '@/modules/auth/NextAuthProvider' import type { Metadata } from 'next' import { getServerSession } from 'next-auth/next' @@ -31,6 +33,8 @@ export default async function RootLayout(props: ChildProps) {children} + + diff --git a/src/components/confirmationModal.tsx b/src/components/confirmationModal.tsx new file mode 100644 index 0000000..85c61b1 --- /dev/null +++ b/src/components/confirmationModal.tsx @@ -0,0 +1,101 @@ +'use client' + +import { useEffect, useState } from 'react' +import { v4 as newUID } from 'uuid' + +import { createEvent } from '@/modules/custom-events/createEvent' +import { mapRecord } from '@/modules/utility/mapRecord' +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material' + +type ConfirmationEvent = { + title?: string, + description?: string, + onConfirmed: (confirmed: boolean) => void +} + +type ConfirmationDialog = ConfirmationEvent & { + display: boolean, + confirmation?: boolean +} + +let isConfirmationModalMounted = false + +const confirmationEvent = createEvent('confirmationEvent') + +export const invokeConfirmationModal = confirmationEvent.callEvent + +export function ConfirmationModal() +{ + const [dialogs, setDialogs] = useState>({}) + + useEffect(() => + { + if (isConfirmationModalMounted) + throw new Error('Another ConfirmationModal is already mounted') + + isConfirmationModalMounted = true + + return () => + { + isConfirmationModalMounted = false + } + }, []) + + confirmationEvent.useEvent((dialog) => + { + const newDialogs = { ...dialogs } + const uid = newUID() + newDialogs[uid] = { + ...dialog, + display: true, + } + setDialogs(newDialogs) + }) + + function confirmationHandler(dialogKey: string, confirm: boolean) + { + const newDialogs = { ...dialogs } + dialogs[dialogKey].display = false + dialogs[dialogKey].confirmation = confirm + setDialogs(newDialogs) + } + + function finalizeDialogConfirmation(dialogKey: string) + { + const dialog = dialogs[dialogKey] + dialog.onConfirmed(dialog.confirmation ?? false) + + const newDialogs = { ...dialogs } + delete newDialogs[dialogKey] + setDialogs(newDialogs) + } + + return ( +
+ {mapRecord(dialogs, (dialog, key) => ( + finalizeDialogConfirmation(key), + }} + > + + {dialog.title ?? 'Confirmation'} + + + + {dialog.description ?? 'Are you sure you want to proceed?'} + + + + + + + + ))} +
+ ) +} diff --git a/src/components/loadingModal.tsx b/src/components/loadingModal.tsx new file mode 100644 index 0000000..2b39cb4 --- /dev/null +++ b/src/components/loadingModal.tsx @@ -0,0 +1,62 @@ +'use client' + +import { createEvent } from '@/modules/custom-events/createEvent' +import { Dialog, DialogContent, LinearProgress, Typography } from '@mui/material' +import { useEffect, useState } from 'react' + +type LoadingEvent = { + display: boolean + textOverride?: string +} + +let isLoadingModalMounted = false + +const style = { + position: 'absolute' as 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 400, + bgcolor: 'background.paper', + border: '2px solid #000', + boxShadow: 24, + p: 4, +} + +const loadingEvent = createEvent('loadingEvent') + +export const invokeLoadingModal = loadingEvent.callEvent + +export default function LoadingModal() +{ + const [state, setState] = useState({ + display: false, + textOverride: undefined + }) + + const label = typeof state.textOverride !== 'undefined' ? state.textOverride : 'Loading' + + useEffect(() => + { + if (isLoadingModalMounted) + throw new Error('Another LoadingModal is already mounted') + + isLoadingModalMounted = true + + return () => + { + isLoadingModalMounted = false + } + }, []) + + loadingEvent.useEvent((event) => setState(event)) + + return ( + + + {label} + + + + ) +} diff --git a/src/modules/custom-events/createEvent.ts b/src/modules/custom-events/createEvent.ts new file mode 100644 index 0000000..3840182 --- /dev/null +++ b/src/modules/custom-events/createEvent.ts @@ -0,0 +1,43 @@ +'use client' + +import { useEffect, useRef } from 'react' + +export function createEvent(eventName: string) +{ + function callEvent(data: T) + { + const event = new CustomEvent(eventName, { detail: data }) + document.dispatchEvent(event) + } + + function useEvent(callback: (data: T) => void) + { + const callbackRef = useRef(callback) + + useEffect(() => + { + callbackRef.current = callback + }, [callback]) + + useEffect(() => + { + + function handleEvent(event: Event) + { + if (event instanceof CustomEvent && event.detail !== undefined) + { + callbackRef.current(event.detail) + } + } + + document.addEventListener(eventName, handleEvent) + + return () => + { + document.removeEventListener(eventName, handleEvent) + } + }, []) + } + + return { callEvent, useEvent } as const +} diff --git a/src/modules/custom-events/events/newProjectEvent.ts b/src/modules/custom-events/events/newProjectEvent.ts new file mode 100644 index 0000000..4d59843 --- /dev/null +++ b/src/modules/custom-events/events/newProjectEvent.ts @@ -0,0 +1,5 @@ +'use client' + +import { createEvent } from '@/modules/custom-events/createEvent' + +export const newProjectEvent = createEvent('newProject') diff --git a/src/modules/database/databaseClient.ts b/src/modules/database/databaseClient.ts index f0a35be..19f582f 100644 --- a/src/modules/database/databaseClient.ts +++ b/src/modules/database/databaseClient.ts @@ -1,5 +1,14 @@ +import { ProjectUpdateValues } from '@/modules/database/requestTypes' +import { AccessTokenDetail, ProjectDetail } from '@/modules/database/responseTypes' export interface DatabaseClient { - getProjectListAsync(): Promise; - createProjectAsync(name: string): Promise; + // Project + getProjectsAsync(): Promise; + createProjectAsync(name: string, isActive: boolean): Promise; + deleteProjectAsync(projectId: string): Promise; + updateProjectAsync(projectId: string, values: ProjectUpdateValues): Promise; + + // Token + createAccessTokenAsync(projectId: string): Promise; + deleteAccessTokenAsync(tokenId: string): Promise; } diff --git a/src/modules/database/models.ts b/src/modules/database/models.ts index 83431c3..6b9f960 100644 --- a/src/modules/database/models.ts +++ b/src/modules/database/models.ts @@ -1,20 +1,20 @@ -type Project = { +export type ProjectModel = { id: string; name: string; active: boolean; meta: Record; - posts: Post[]; - accessTokens: AccessToken[]; + posts: PostModel[]; + accessTokens: AccessTokenModel[]; }; -type AccessToken = { +export type AccessTokenModel = { id: string; idProject: string; token: string; - project: Project | null; + project: ProjectModel | null; } -type Post = { +export type PostModel = { id: string; idProject: string; title: string; @@ -25,11 +25,7 @@ type Post = { meta: Record; tags: string[]; status: PostStatus; - project: Project | null; + project: ProjectModel | null; } -type PostStatus = 'ACTIVE' | 'DISABLED' | 'HIDDEN' - -type ProjectDetail = Pick & { - accessTokens: Pick[]; -}; +export type PostStatus = 'ACTIVE' | 'DISABLED' | 'HIDDEN' diff --git a/src/modules/database/requestTypes.ts b/src/modules/database/requestTypes.ts new file mode 100644 index 0000000..97a3003 --- /dev/null +++ b/src/modules/database/requestTypes.ts @@ -0,0 +1,5 @@ +import { ProjectModel } from '@/modules/database/models' + +export type ProjectUpdateValues = { + [K in keyof Pick]?: ProjectModel[K] +}; diff --git a/src/modules/database/responseTypes.ts b/src/modules/database/responseTypes.ts new file mode 100644 index 0000000..4d3f673 --- /dev/null +++ b/src/modules/database/responseTypes.ts @@ -0,0 +1,7 @@ +import { AccessTokenModel, ProjectModel } from '@/modules/database/models' + +export type ProjectDetail = Pick & { + accessTokens: Pick[]; +}; + +export type AccessTokenDetail = Pick; diff --git a/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts b/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts index 3d0990c..259af15 100644 --- a/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts +++ b/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts @@ -1,6 +1,21 @@ import { DatabaseClient } from '@/modules/database/databaseClient' +import { ProjectUpdateValues } from '@/modules/database/requestTypes' +import { AccessTokenDetail, ProjectDetail } from '@/modules/database/responseTypes' import { Prisma, PrismaClient } from '@prisma/client' +const projectDetailSelect = { + id: true, + name: true, + active: true, + meta: true, + accessTokens: { + select: { + id: true, + token: true + } + } +} + export class PrismaCockroachDatabaseClient implements DatabaseClient { private prisma: PrismaClient @@ -10,32 +25,17 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient this.prisma = new PrismaClient() } - private getPrismaJsonValue(jsonValue: Prisma.JsonValue) - { - return (jsonValue?.valueOf() ?? {}) as T - } - - async getProjectListAsync(): Promise + //Project + async getProjectsAsync(): Promise { const projects = await this.prisma.project.findMany({ - select: { - id: true, - name: true, - active: true, - meta: true, - accessTokens: { - select: { - id: true, - token: true - } - } - } + select: projectDetailSelect }) return projects.map(x => ({...x, meta: this.getPrismaJsonValue(x.meta)})) } - async createProjectAsync(name: string): Promise + async createProjectAsync(name: string, isActive: boolean): Promise { const matchingProjectNameCount = await this.prisma.project.count({ where: { @@ -51,26 +51,74 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient const project = await this.prisma.project.create({ data: { name, - active: true, + active: isActive, meta: {}, accessTokens: { create: {} } }, - select: { - id: true, - name: true, - active: true, - meta: true, - accessTokens: { - select: { - id: true, - token: true + select: projectDetailSelect + }) + + return {...project, meta: this.getPrismaJsonValue(project.meta)} + } + + async deleteProjectAsync(projectId: string): Promise + { + await this.prisma.project.delete({ + where: { + id: projectId + } + }) + } + + async updateProjectAsync(projectId: string, values: ProjectUpdateValues): Promise + { + const project = await this.prisma.project.update({ + where: { + id: projectId + }, + data: { + ...values + }, + select: projectDetailSelect + }) + + return {...project, meta: this.getPrismaJsonValue(project.meta)} + } + + //Token + async createAccessTokenAsync(projectId: string): Promise + { + const token = await this.prisma.accessToken.create({ + data: { + project: { + connect: { + id: projectId } } + }, + select: { + id: true, + token: true, + idProject: true, } }) - return {...project, meta: this.getPrismaJsonValue(project.meta)} + return {...token, idProject: token.idProject!} + } + + async deleteAccessTokenAsync(tokenId: string): Promise + { + await this.prisma.accessToken.delete({ + where: { + id: tokenId + } + }) + } + + private getPrismaJsonValue(jsonValue: Prisma.JsonValue) + { + return (jsonValue?.valueOf() ?? {}) as T } } diff --git a/src/modules/utility/mapRecord.ts b/src/modules/utility/mapRecord.ts new file mode 100644 index 0000000..52bfb2f --- /dev/null +++ b/src/modules/utility/mapRecord.ts @@ -0,0 +1,4 @@ +export function mapRecord(record: Record, callback: (value: V, key: K) => R): R[] +{ + return Object.entries(record).map(([key, value]) => callback(value as V, key as K)) +} diff --git a/tsconfig.json b/tsconfig.json index c7e0304..b8d08f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "allowJs": true, "skipLibCheck": true, "strict": true, + "noImplicitAny": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", @@ -32,7 +33,8 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + "src/app/dashboard/_actions" ], "exclude": [ "node_modules" From 5a847ae5816aacf7b8ab31d1c6e9f16b68430d57 Mon Sep 17 00:00:00 2001 From: Harley John Torrisi Date: Sun, 17 Mar 2024 13:45:10 +1000 Subject: [PATCH 4/7] Masked token in project list. --- .../dashboard/projects/_components/ProjectListItem.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/dashboard/projects/_components/ProjectListItem.tsx b/src/app/dashboard/projects/_components/ProjectListItem.tsx index 0a5a912..98237fd 100644 --- a/src/app/dashboard/projects/_components/ProjectListItem.tsx +++ b/src/app/dashboard/projects/_components/ProjectListItem.tsx @@ -97,7 +97,13 @@ export function ProjectListItem(props: Props) } }} > - {token.token} + + { + token.token.slice(0, 4) + + '.'.repeat(token.token.length - 8) + + token.token.slice(-4) + } + navigator.clipboard.writeText(token.token)} From be6776f658c740ce6b6d7fe7a9de66d2f9ef0482 Mon Sep 17 00:00:00 2001 From: Harley John Torrisi Date: Sun, 17 Mar 2024 23:40:11 +1000 Subject: [PATCH 5/7] Added meta data editor --- .../projects/_components/ProjectList.tsx | 4 +- .../projects/_components/ProjectListItem.tsx | 15 +- src/app/layout.tsx | 4 +- ...rmationModal.tsx => ConfirmationModal.tsx} | 0 .../{loadingModal.tsx => LoadingModal.tsx} | 0 src/components/MetaDataEditor.tsx | 162 ++++++++++++++++++ src/components/authDemo.tsx | 21 --- src/modules/utility/recordToArray.ts | 4 + 8 files changed, 181 insertions(+), 29 deletions(-) rename src/components/{confirmationModal.tsx => ConfirmationModal.tsx} (100%) rename src/components/{loadingModal.tsx => LoadingModal.tsx} (100%) create mode 100644 src/components/MetaDataEditor.tsx delete mode 100644 src/components/authDemo.tsx create mode 100644 src/modules/utility/recordToArray.ts diff --git a/src/app/dashboard/projects/_components/ProjectList.tsx b/src/app/dashboard/projects/_components/ProjectList.tsx index a9f559a..b7a35ab 100644 --- a/src/app/dashboard/projects/_components/ProjectList.tsx +++ b/src/app/dashboard/projects/_components/ProjectList.tsx @@ -12,8 +12,8 @@ import { createProjectTokenServerAction, deleteProjectTokenServerAction } from '@/app/dashboard/_actions/accessTokenActions' import { deleteProjectServerAction, updateProjectServerAction } from '@/app/dashboard/_actions/projectActions' import { ProjectListItem } from '@/app/dashboard/projects/_components/ProjectListItem' -import { invokeConfirmationModal } from '@/components/confirmationModal' -import { invokeLoadingModal } from '@/components/loadingModal' +import { invokeConfirmationModal } from '@/components/ConfirmationModal' +import { invokeLoadingModal } from '@/components/LoadingModal' import { ProjectUpdateValues } from '@/modules/database/requestTypes' import { ProjectDetail } from '@/modules/database/responseTypes' import { useCallback, useMemo, useState } from 'react' diff --git a/src/app/dashboard/projects/_components/ProjectListItem.tsx b/src/app/dashboard/projects/_components/ProjectListItem.tsx index 98237fd..33678c6 100644 --- a/src/app/dashboard/projects/_components/ProjectListItem.tsx +++ b/src/app/dashboard/projects/_components/ProjectListItem.tsx @@ -1,5 +1,6 @@ 'use client' +import { MetaDataEditor } from '@/components/MetaDataEditor' import { ProjectUpdateValues } from '@/modules/database/requestTypes' import { ProjectDetail } from '@/modules/database/responseTypes' import @@ -10,6 +11,7 @@ import Save as SaveIcon } from '@mui/icons-material' import { Accordion, AccordionActions, AccordionDetails, AccordionSummary, Box, Button, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography } from '@mui/material' +import { useState } from 'react' type Props = { project: ProjectDetail @@ -23,9 +25,6 @@ type Props = { setActiveProject: (projectId: string) => void } -// TODO: Meta Component -// TODO: Mask token - export function ProjectListItem(props: Props) { const { @@ -40,6 +39,8 @@ export function ProjectListItem(props: Props) setActiveProject } = props + const [metaDataValid, setMetaDataValid] = useState(true) + return ( + + updateDetail({ meta: data })} + onDataValidation={(isValid) => setMetaDataValid(isValid)} + /> @@ -133,7 +140,7 @@ export function ProjectListItem(props: Props) Delete - diff --git a/src/app/layout.tsx b/src/app/layout.tsx index feb37af..a9ff297 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,8 +4,8 @@ import '@fontsource/roboto/400.css' import '@fontsource/roboto/500.css' import '@fontsource/roboto/700.css' -import { ConfirmationModal } from '@/components/confirmationModal' -import LoadingModal from '@/components/loadingModal' +import { ConfirmationModal } from '@/components/ConfirmationModal' +import LoadingModal from '@/components/LoadingModal' import { NextAuthProvider } from '@/modules/auth/NextAuthProvider' import type { Metadata } from 'next' import { getServerSession } from 'next-auth/next' diff --git a/src/components/confirmationModal.tsx b/src/components/ConfirmationModal.tsx similarity index 100% rename from src/components/confirmationModal.tsx rename to src/components/ConfirmationModal.tsx diff --git a/src/components/loadingModal.tsx b/src/components/LoadingModal.tsx similarity index 100% rename from src/components/loadingModal.tsx rename to src/components/LoadingModal.tsx diff --git a/src/components/MetaDataEditor.tsx b/src/components/MetaDataEditor.tsx new file mode 100644 index 0000000..7f2083f --- /dev/null +++ b/src/components/MetaDataEditor.tsx @@ -0,0 +1,162 @@ +'use client' + +import { recordToArray } from '@/modules/utility/recordToArray' +import +{ + Add as AddIcon, + Css as CssIcon, + Delete as DeleteIcon, + Facebook as FacebookIcon, + GitHub as GitHubIcon, + Language as LanguageIcon, + Link as LinkIcon, + LinkedIn as LinkedInIcon, + Tag as TagIcon, + Twitter as TwitterIcon, + YouTube as YoutubeIcon +} from '@mui/icons-material' +import { IconButton, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField } from '@mui/material' +import { useEffect, useState } from 'react' + +const knownMetaKeys: Record JSX.Element> = { + 'github': () => , + 'facebook': () => , + 'linkedin': () => , + 'twitter': () => , + 'youtube': () => , + 'css': () => , + 'link': () => , + 'url': () => , + 'web': () => +} + +type Props = { + meta: Record + onMetaChange: (metaData: Record) => void + onDataValidation: (isValid: boolean) => void +} + +export function MetaDataEditor(props: Props) +{ + const { meta, onMetaChange, onDataValidation } = props + + const [workingSet, setWorkingSet] = useState(recordToArray(meta)) + const [newKeyTemp, setNewKeyTemp] = useState('') + + useEffect(() => + { + if (JSON.stringify(recordToArray(meta)) === JSON.stringify(workingSet)) return + + const isValid = allKeysValid() + + if (isValid) + onMetaChange(Object.fromEntries(workingSet.map(item => [item.key, item.value]))) + + onDataValidation(isValid) + + function allKeysValid(): boolean + { + const keys = workingSet.map(item => item.key) + const uniqueKeys = new Set(keys) + return keys.length === uniqueKeys.size && !keys.some(key => key === '') + } + }, [meta, onDataValidation, onMetaChange, workingSet]) + + function isKeyInvalid(key: string) + { + return key === '' || workingSet.filter(x => x.key == key).length > 1 + } + + function deleteKeyHandler(index: number) + { + setWorkingSet(workingSet.filter((item, i) => i !== index)) + } + + function addKeyHandler() + { + if (!newKeyTemp) return + setWorkingSet([...workingSet, { key: newKeyTemp, value: '' }]) + setNewKeyTemp('') + } + + function updateKeyHandler(index: number, key: string) + { + setWorkingSet(workingSet.map((item, i) => i === index ? { ...item, key } : item)) + } + + function updateValueHandler(index: number, value: string) + { + setWorkingSet(workingSet.map((item, i) => i === index ? { ...item, value } : item)) + } + + return ( + + + + + Meta + Key + Value + + + + + {workingSet.map((item, index) => ( + + + {knownMetaKeys[item.key] ? knownMetaKeys[item.key]() : } + + + updateKeyHandler(index, e.currentTarget.value)} + error={isKeyInvalid(item.key)} + + /> + + + updateValueHandler(index, e.currentTarget.value)} + /> + + + deleteKeyHandler(index)}> + + + + + ))} + + + {knownMetaKeys[newKeyTemp] ? knownMetaKeys[newKeyTemp]() : } + + + setNewKeyTemp(e.currentTarget.value)} + /> + + + + + + + + +
+
+ ) +} diff --git a/src/components/authDemo.tsx b/src/components/authDemo.tsx deleted file mode 100644 index 8458106..0000000 --- a/src/components/authDemo.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client' - -import { signIn, signOut } from 'next-auth/react' - -export const LoginButton = () => -{ - return ( - - ) -} - -export const LogoutButton = () => -{ - return ( - - ) -} diff --git a/src/modules/utility/recordToArray.ts b/src/modules/utility/recordToArray.ts new file mode 100644 index 0000000..d826abf --- /dev/null +++ b/src/modules/utility/recordToArray.ts @@ -0,0 +1,4 @@ +export function recordToArray(record: Record) +{ + return Object.entries(record).map(([key, value]) => ({ key, value })) as { key: K, value: V }[] +} From 13ed13a93fb696a83d85bf946376f3b23c83cd66 Mon Sep 17 00:00:00 2001 From: Harley John Torrisi Date: Mon, 18 Mar 2024 18:58:44 +1000 Subject: [PATCH 6/7] Extract token list to seperate component --- .../projects/_components/ProjectListItem.tsx | 63 ++-------------- .../projects/_components/TokenList.tsx | 75 +++++++++++++++++++ 2 files changed, 82 insertions(+), 56 deletions(-) create mode 100644 src/app/dashboard/projects/_components/TokenList.tsx diff --git a/src/app/dashboard/projects/_components/ProjectListItem.tsx b/src/app/dashboard/projects/_components/ProjectListItem.tsx index 33678c6..add934d 100644 --- a/src/app/dashboard/projects/_components/ProjectListItem.tsx +++ b/src/app/dashboard/projects/_components/ProjectListItem.tsx @@ -1,16 +1,16 @@ 'use client' +import { TokenList } from '@/app/dashboard/projects/_components/TokenList' import { MetaDataEditor } from '@/components/MetaDataEditor' import { ProjectUpdateValues } from '@/modules/database/requestTypes' import { ProjectDetail } from '@/modules/database/responseTypes' import { - ContentCopy as ContentCopyIcon, Delete as DeleteIcon, ExpandMore as ExpandMoreIcon, Save as SaveIcon } from '@mui/icons-material' -import { Accordion, AccordionActions, AccordionDetails, AccordionSummary, Box, Button, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography } from '@mui/material' +import { Accordion, AccordionActions, AccordionDetails, AccordionSummary, Box, Button, FormControl, InputLabel, MenuItem, Select, Stack, TextField, Typography } from '@mui/material' import { useState } from 'react' type Props = { @@ -71,60 +71,11 @@ export function ProjectListItem(props: Props) - - - - - Tokens - - - - - - - - {project.accessTokens.map((token) => ( - - - button': { - visibility: 'visible' - } - }} - > - - { - token.token.slice(0, 4) + - '.'.repeat(token.token.length - 8) + - token.token.slice(-4) - } - - navigator.clipboard.writeText(token.token)} - > - - - - - - deleteToken(token.id)}> - - - - - ))} - -
-
+ [] + createToken: () => void + deleteToken: (tokenId: string) => void +} + +export function TokenList(props: Props) +{ + const { accessTokens, createToken, deleteToken } = props + + return ( + + + + + Tokens + + + + + + + + {accessTokens.map((token) => ( + + + button': { + visibility: 'visible' + } + }} + > + + { + token.token.slice(0, 4) + + '.'.repeat(token.token.length - 8) + + token.token.slice(-4) + } + + navigator.clipboard.writeText(token.token)} + > + + + + + + deleteToken(token.id)}> + + + + + ))} + +
+
+ ) +} From 0e8b6f52735ba1982df4b49576b175315599cd37 Mon Sep 17 00:00:00 2001 From: Harley John Torrisi Date: Mon, 18 Mar 2024 21:14:02 +1000 Subject: [PATCH 7/7] Readded create project functionality. --- .../_components/CreateProjectButton.tsx | 4 +- .../_components/CreateProjectDialog.tsx | 98 +++++++++++++++++++ .../{ProjectList.tsx => ProjectView.tsx} | 26 ++++- src/app/dashboard/projects/page.tsx | 5 +- src/components/LoadingModal.tsx | 12 --- src/components/MetaDataEditor.tsx | 10 +- .../custom-events/events/newProjectEvent.ts | 5 - 7 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 src/app/dashboard/projects/_components/CreateProjectDialog.tsx rename src/app/dashboard/projects/_components/{ProjectList.tsx => ProjectView.tsx} (87%) delete mode 100644 src/modules/custom-events/events/newProjectEvent.ts diff --git a/src/app/dashboard/projects/_components/CreateProjectButton.tsx b/src/app/dashboard/projects/_components/CreateProjectButton.tsx index 44f708c..43b7185 100644 --- a/src/app/dashboard/projects/_components/CreateProjectButton.tsx +++ b/src/app/dashboard/projects/_components/CreateProjectButton.tsx @@ -1,11 +1,11 @@ 'use client' -import { newProjectEvent } from '@/modules/custom-events/events/newProjectEvent' +import { invokeNewProjectRequest } from '@/app/dashboard/projects/_components/CreateProjectDialog' import { Button } from '@mui/material' export function CreateProjectButton() { return ( - + ) } diff --git a/src/app/dashboard/projects/_components/CreateProjectDialog.tsx b/src/app/dashboard/projects/_components/CreateProjectDialog.tsx new file mode 100644 index 0000000..68de338 --- /dev/null +++ b/src/app/dashboard/projects/_components/CreateProjectDialog.tsx @@ -0,0 +1,98 @@ +'use client' + +import { invokeConfirmationModal } from '@/components/ConfirmationModal' +import { createEvent } from '@/modules/custom-events/createEvent' +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material' +import { useState } from 'react' + +type Props = { + currentProjectNames: string[] + onCreateProject: (name: string) => void +} + +const newProjectRequestEvent = createEvent('newProjectRequest') + +export const invokeNewProjectRequest = () => newProjectRequestEvent.callEvent(null) + +const defaultState = { + display: false, + name: '', + nameInUse: false, + process: false +} + +export function CreateProjectDialog(props: Props) +{ + const { currentProjectNames, onCreateProject } = props + + const [state, setState] = useState(defaultState) + + newProjectRequestEvent.useEvent(() => + { + setState({ ...defaultState, display: true }) + }) + + function setNameHandler(e: React.ChangeEvent) + { + setState({ + ...state, + name: e.currentTarget.value, + nameInUse: currentProjectNames.includes(e.currentTarget.value) + }) + } + + function cancelHandler() + { + setState({ ...state, display: false, process: false }) + } + + function createStartHandler() + { + if (state.nameInUse) return + + invokeConfirmationModal({ + description: `Are you sure you want to create a project named "${state.name}"?`, + onConfirmed: (confirmed) => + { + if (confirmed) + { + setState({ ...state, display: false, process: true }) + } + } + }) + } + + function exitHandler() + { + if (state.process) onCreateProject(state.name) + setState(defaultState) + } + + return ( + + Create Project + + + + + + + + + + ) +} diff --git a/src/app/dashboard/projects/_components/ProjectList.tsx b/src/app/dashboard/projects/_components/ProjectView.tsx similarity index 87% rename from src/app/dashboard/projects/_components/ProjectList.tsx rename to src/app/dashboard/projects/_components/ProjectView.tsx index b7a35ab..0e9630b 100644 --- a/src/app/dashboard/projects/_components/ProjectList.tsx +++ b/src/app/dashboard/projects/_components/ProjectView.tsx @@ -10,7 +10,8 @@ 'use client' import { createProjectTokenServerAction, deleteProjectTokenServerAction } from '@/app/dashboard/_actions/accessTokenActions' -import { deleteProjectServerAction, updateProjectServerAction } from '@/app/dashboard/_actions/projectActions' +import { createProjectServerAction, deleteProjectServerAction, updateProjectServerAction } from '@/app/dashboard/_actions/projectActions' +import { CreateProjectDialog } from '@/app/dashboard/projects/_components/CreateProjectDialog' import { ProjectListItem } from '@/app/dashboard/projects/_components/ProjectListItem' import { invokeConfirmationModal } from '@/components/ConfirmationModal' import { invokeLoadingModal } from '@/components/LoadingModal' @@ -18,7 +19,7 @@ import { ProjectUpdateValues } from '@/modules/database/requestTypes' import { ProjectDetail } from '@/modules/database/responseTypes' import { useCallback, useMemo, useState } from 'react' -export function ProjectList(props: { projects: ProjectDetail[] }) +export function ProjectView(props: { projects: ProjectDetail[] }) { const [projects, setProjects] = useState(props.projects) const [activeProject, setActiveProject] = useState(projects[0]) @@ -57,6 +58,22 @@ export function ProjectList(props: { projects: ProjectDetail[] }) setActiveProject(updatedProject) } + async function createProjectHandler(newName: string) + { + const invokeLoading = (display: boolean) => invokeLoadingModal({ display, textOverride: 'Creating Project' }) + + invokeLoading(true) + + const newProject = await createProjectServerAction(newName, true) + + setProjects([newProject, ...projects]) + setActiveProject(newProject) + + invokeLoading(false) + + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + async function saveActiveProjectHandler() { if (!activeProject) return @@ -190,6 +207,11 @@ export function ProjectList(props: { projects: ProjectDetail[] }) setActiveProject={setActiveProjectHandler} /> ))} + + x.name)} + onCreateProject={createProjectHandler} + />
) } diff --git a/src/app/dashboard/projects/page.tsx b/src/app/dashboard/projects/page.tsx index 2b0529d..fabc182 100644 --- a/src/app/dashboard/projects/page.tsx +++ b/src/app/dashboard/projects/page.tsx @@ -1,5 +1,5 @@ import { CreateProjectButton } from '@/app/dashboard/projects/_components/CreateProjectButton' -import { ProjectList } from '@/app/dashboard/projects/_components/ProjectList' +import { ProjectView } from '@/app/dashboard/projects/_components/ProjectView' import { getDatabaseClientAsync } from '@/modules/database/databaseFactory' import { Stack, Typography } from '@mui/material' @@ -20,8 +20,7 @@ export default async function ProjectsPage() Projects - - {/* */} + ) } diff --git a/src/components/LoadingModal.tsx b/src/components/LoadingModal.tsx index 2b39cb4..cc76f5e 100644 --- a/src/components/LoadingModal.tsx +++ b/src/components/LoadingModal.tsx @@ -11,18 +11,6 @@ type LoadingEvent = { let isLoadingModalMounted = false -const style = { - position: 'absolute' as 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: 400, - bgcolor: 'background.paper', - border: '2px solid #000', - boxShadow: 24, - p: 4, -} - const loadingEvent = createEvent('loadingEvent') export const invokeLoadingModal = loadingEvent.callEvent diff --git a/src/components/MetaDataEditor.tsx b/src/components/MetaDataEditor.tsx index 7f2083f..9e6c9f1 100644 --- a/src/components/MetaDataEditor.tsx +++ b/src/components/MetaDataEditor.tsx @@ -36,6 +36,11 @@ type Props = { onDataValidation: (isValid: boolean) => void } +function MetaIcon({ metaKey }: { metaKey: string }) +{ + return (knownMetaKeys[metaKey.toLowerCase()] ?? (() => ))() +} + export function MetaDataEditor(props: Props) { const { meta, onMetaChange, onDataValidation } = props @@ -104,7 +109,7 @@ export function MetaDataEditor(props: Props) {workingSet.map((item, index) => ( - {knownMetaKeys[item.key] ? knownMetaKeys[item.key]() : } + updateKeyHandler(index, e.currentTarget.value)} error={isKeyInvalid(item.key)} - /> @@ -136,7 +140,7 @@ export function MetaDataEditor(props: Props) ))} - {knownMetaKeys[newKeyTemp] ? knownMetaKeys[newKeyTemp]() : } + ('newProject')