diff --git a/.vscode/settings.json b/.vscode/settings.json index 47600f8..3334f63 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,7 +35,7 @@ "editor.tabSize": 2 }, "[typescript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint" + "editor.defaultFormatter": "vscode.typescript-language-features" }, "[javascript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" @@ -43,5 +43,8 @@ "[typescriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, + "[javascriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, "workbench.editor.labelFormat": "short", } diff --git a/bun.lockb b/bun.lockb index df581f2..61717de 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/app/dashboard/_actions/postActions.ts b/src/app/dashboard/_actions/postActions.ts new file mode 100644 index 0000000..e520c6e --- /dev/null +++ b/src/app/dashboard/_actions/postActions.ts @@ -0,0 +1,32 @@ +'use server' + +import { getDatabaseClientAsync } from '@/modules/database/databaseFactory' +import { PostUpdateBlockValues, PostUpdateDetailsValues } from '@/modules/database/requestTypes' +import { PostBlocks, PostDetail } from '@/modules/database/responseTypes' + +export async function createPostServerAction(postName: string, projectId: string): Promise +{ + const client = await getDatabaseClientAsync() + const post = await client.createPostAsync(postName, projectId) + return post +} + +export async function updatePostServerAction(postId: string, values: PostUpdateDetailsValues): Promise +{ + const client = await getDatabaseClientAsync() + const post = await client.updatePostDetailsAsync(postId, values) + return post +} + +export async function updatePostBlocksServerAction(postId: string, values: PostUpdateBlockValues): Promise +{ + const client = await getDatabaseClientAsync() + const blocks = await client.updatePostBlocksAsync(postId, values) + return blocks +} + +export async function deletePostServerAction(postId: string): Promise +{ + const client = await getDatabaseClientAsync() + await client.deletePostAsync(postId) +} diff --git a/src/app/dashboard/files/page.tsx b/src/app/dashboard/files/page.tsx index a50d652..a5274c5 100644 --- a/src/app/dashboard/files/page.tsx +++ b/src/app/dashboard/files/page.tsx @@ -3,7 +3,7 @@ import { NewFileButton } from '@/app/dashboard/files/_components/NewFileButton' import { getDatabaseClientAsync } from '@/modules/database/databaseFactory' import { Stack, Typography } from '@mui/material' -const getInitialFilesAsync = async () => +const getDataAsync = async () => { const client = await getDatabaseClientAsync() const files = await client.getDataFilesPaginatedAsync(0, Number(process.env.PAGINATION_PAGE_SIZE)) @@ -12,7 +12,7 @@ const getInitialFilesAsync = async () => export default async function FileManagerPage() { - const files = await getInitialFilesAsync() + const files = await getDataAsync() return ( diff --git a/src/app/dashboard/posts/_components/CreatePostButton.tsx b/src/app/dashboard/posts/_components/CreatePostButton.tsx new file mode 100644 index 0000000..88cc7b7 --- /dev/null +++ b/src/app/dashboard/posts/_components/CreatePostButton.tsx @@ -0,0 +1,11 @@ +'use client' + +import { invokeNewPostRequest } from '@/app/dashboard/posts/_components/CreatePostDialog' +import { Button } from '@mui/material' + +export function CreatePostButton() +{ + return ( + + ) +} diff --git a/src/app/dashboard/posts/_components/CreatePostDialog.tsx b/src/app/dashboard/posts/_components/CreatePostDialog.tsx new file mode 100644 index 0000000..b99d846 --- /dev/null +++ b/src/app/dashboard/posts/_components/CreatePostDialog.tsx @@ -0,0 +1,153 @@ +'use client' + +import { invokeConfirmationModal } from '@/components/ConfirmationModal' +import { invokeMessageAlertModal } from '@/components/MessageAlertModal' +import { createEvent } from '@/modules/custom-events/createEvent' +import { ProjectListDetail } from '@/modules/database/responseTypes' +import { Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, Stack, TextField, Typography } from '@mui/material' +import { useState } from 'react' + +const newPostRequestEvent = createEvent('newPostRequest') + +export const invokeNewPostRequest = () => newPostRequestEvent.callEvent(null) + +const defaultState = { + display: false, + name: '', + nameInUse: false, + projectId: '', + projectSelected: false, + process: false +} + +type Props = { + currentPostNames: string[] + projects: ProjectListDetail[] + onCreatePost: (name: string, projectId: string) => void +} + +export function CreatePostDialog(props: Props) +{ + const { currentPostNames, projects, onCreatePost } = props + + const [state, setState] = useState(defaultState) + + newPostRequestEvent.useEvent(() => + { + setState({ ...defaultState, display: true }) + }) + + function setNameHandler(e: React.ChangeEvent) + { + setState({ + ...state, + name: e.currentTarget.value, + nameInUse: currentPostNames.includes(e.currentTarget.value) + }) + } + + function setProjectHandler(e: SelectChangeEvent) + { + setState({ + ...state, + projectId: e.target.value, + projectSelected: !!e.target.value + }) + } + + function cancelHandler() + { + setState({ ...state, display: false, process: false }) + } + + function createStartHandler() + { + if (state.nameInUse) return + + if (!state.name.trim()) + { + invokeMessageAlertModal({ + title: 'Missing Name', + description: 'You must choose a name first, and cannot by only spaces.', + }) + return + } + + if (!state.projectSelected) + { + invokeMessageAlertModal({ + title: 'Missing Project', + description: 'You must choose a project first.', + }) + return + } + + invokeConfirmationModal({ + description: `Are you sure you want to create a post named "${state.name}"?`, + onConfirmed: (confirmed) => + { + if (confirmed) + { + setState({ ...state, display: false, process: true }) + } + } + }) + } + + function exitHandler() + { + if (state.process) onCreatePost(state.name, state.projectId) + setState(defaultState) + } + + return ( + + Create Post + + + + Assign Project + + + + + + + + + ) +} diff --git a/src/app/dashboard/posts/_components/PostListItem.tsx b/src/app/dashboard/posts/_components/PostListItem.tsx new file mode 100644 index 0000000..d9c1077 --- /dev/null +++ b/src/app/dashboard/posts/_components/PostListItem.tsx @@ -0,0 +1,191 @@ +import { TagsInput } from '@/app/dashboard/posts/_components/TagsInput' +import DatePicker from '@/components/DatePicker' +import { ImageBox } from '@/components/ImageBox' +import { MetaDataEditor } from '@/components/MetaDataEditor' +import { PostStatus } from '@/modules/database/models' +import { PostUpdateDetailsValues } from '@/modules/database/requestTypes' +import { PostDetail } from '@/modules/database/responseTypes' +import +{ + Delete as DeleteIcon, + ExpandMore as ExpandMoreIcon, + Image as ImageIcon, + Restore as RestoreIcon, + Save as SaveIcon +} from '@mui/icons-material' +import +{ + Accordion, + AccordionActions, + AccordionDetails, + AccordionSummary, + Box, + Button, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography +} from '@mui/material' +import { useRef, useState } from 'react' + +type Props = { + post: PostDetail + expanded: boolean + detailsChangePending: boolean + updateDetail: (values: PostUpdateDetailsValues) => void + savePost: () => void + deletePost: () => void + discardChanges: () => void + setActivePost: (postId: string) => void +} + +export function PostListItem(props: Props) +{ + const { + post, + expanded, + detailsChangePending, + updateDetail, + savePost, + deletePost, + discardChanges, + setActivePost + } = props + + const [metaDataValid, setMetaDataValid] = useState(true) + + const descriptionRef = useRef(null) + + return ( + setActivePost(post.id)} + > + }> + {post.title} + + + + + updateDetail({ title: e.currentTarget.value })} + inputProps={{ maxLength: 250 }} + /> + + + + updateDetail({ date })} + /> + + + + + Post Status + + + + + + + + updateDetail({ description: e.currentTarget.value })} + multiline + inputProps={{ maxLength: 500 }} + minRows={8} + ref={descriptionRef} + + /> + + + + updateDetail({ featuredImageURL: value })} + forcedHeight={descriptionRef.current?.offsetHeight} + /> + + + + + updateDetail({ tags: value })} /> + + updateDetail({ meta: data })} + onDataValidation={(isValid) => setMetaDataValid(isValid)} + /> + + + + + + + + + + + + + ) +} + + +function FeaturedImageInput(props: { featuredImageURL?: string, forcedHeight?: number, onChange: (value: string) => void }) +{ + const { featuredImageURL, forcedHeight = 217, onChange } = props + + return ( + + onChange(e.currentTarget.value)} + /> + + {featuredImageURL && } + {!featuredImageURL && } + + + ) +} diff --git a/src/app/dashboard/posts/_components/PostsListView.tsx b/src/app/dashboard/posts/_components/PostsListView.tsx new file mode 100644 index 0000000..661f52b --- /dev/null +++ b/src/app/dashboard/posts/_components/PostsListView.tsx @@ -0,0 +1,173 @@ +'use client' + +import { createPostServerAction, deletePostServerAction, updatePostServerAction } from '@/app/dashboard/_actions/postActions' +import { CreatePostDialog } from '@/app/dashboard/posts/_components/CreatePostDialog' +import { PostListItem } from '@/app/dashboard/posts/_components/PostListItem' +import { invokeConfirmationModal } from '@/components/ConfirmationModal' +import { invokeLoadingModal } from '@/components/LoadingModal' +import { PostUpdateDetailsValues } from '@/modules/database/requestTypes' +import { PostDetail, ProjectListDetail } from '@/modules/database/responseTypes' +import { useCallback, useMemo, useState } from 'react' + +type Props = { + posts: PostDetail[] + projects: ProjectListDetail[] +} + +export function PostsListView(props: Props) +{ + const [posts, setPosts] = useState(props.posts) + const [activePost, setActivePost] = useState(posts[0]) + + const postNames = posts.map(x => x.title) + + const hasActivePostDetailsChanged = useMemo(() => + { + if (!activePost) return false + + const originalPost = posts.find(x => x.id === activePost.id) + if (!originalPost) return false + + const details = (post: PostDetail) => JSON.stringify({ + title: post.title, + description: post.description, + featuredImageURL: post.featuredImageURL, + date: post.date, + meta: post.meta, + tags: post.tags, + status: post.status + }) + + return details(activePost) !== details(originalPost) + }, [activePost, posts]) + + const checkPostIsActive = useCallback((postId: string) => activePost?.id === postId, [activePost]) + + function setActivePostHandler(postId: string) + { + if (postId === activePost?.id) return + + const post = posts.find(x => x.id === postId) + if (post) setActivePost(post) + } + + function updateActivePostHandler(values: PostUpdateDetailsValues) + { + if (!activePost) return + + const updatedPost = { ...activePost, ...values } + setActivePost(updatedPost) + } + + function discardActivePostChangesHandler() + { + invokeConfirmationModal({ + description: 'Are you sure you want to discard these changes?', + onConfirmed: (confirmed) => confirmed && discard(), + }) + + function discard() + { + if (!activePost) return + + const originalPost = posts.find(x => x.id === activePost.id) + if (!originalPost) return + + setActivePost(originalPost) + } + } + + async function createPostHandler(newName: string, projectId: string) + { + const invokeLoading = (display: boolean) => invokeLoadingModal({ display, textOverride: 'Creating Post' }) + + invokeLoading(true) + { + const newPost = await createPostServerAction(newName, projectId) + setPosts([newPost, ...posts]) + setActivePost(newPost) + } + invokeLoading(false) + + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + async function saveActivePostHandler() + { + if (!activePost) return + + invokeConfirmationModal({ + description: 'Are you sure you want to save changes to this post?', + onConfirmed: (confirmed) => confirmed && save(), + }) + + const invokeLoading = (display: boolean) => invokeLoadingModal({ display, textOverride: 'Saving Post' }) + + const save = async () => + { + invokeLoading(true) + + const updatedPost = await updatePostServerAction(activePost.id, { + title: activePost.title, + description: activePost.description, + featuredImageURL: activePost.featuredImageURL, + date: activePost.date, + meta: activePost.meta, + tags: activePost.tags, + status: activePost.status + }) + + setPosts(posts.map(post => + post.id === activePost.id + ? updatedPost + : post + )) + + invokeLoading(false) + } + } + + async function deleteActivePostHandler() + { + if (!activePost) return + + invokeConfirmationModal({ + description: 'Are you sure you want to delete this post?', + onConfirmed: (confirmed) => confirmed && save(), + }) + + const invokeLoading = (display: boolean) => invokeLoadingModal({ display, textOverride: 'Deleting Post' }) + + const save = async () => + { + invokeLoading(true) + { + await deletePostServerAction(activePost.id) + const updatedPostList = posts.filter(post => post.id !== activePost.id) + setPosts(updatedPostList) + setActivePost(updatedPostList[0] ?? undefined) + } + invokeLoading(false) + } + } + + return ( +
+ {posts.map(post => ( + + ))} + + +
+ ) +} diff --git a/src/app/dashboard/posts/_components/TagsInput.tsx b/src/app/dashboard/posts/_components/TagsInput.tsx new file mode 100644 index 0000000..bf10a84 --- /dev/null +++ b/src/app/dashboard/posts/_components/TagsInput.tsx @@ -0,0 +1,52 @@ +import { Autocomplete, Chip, TextField } from '@mui/material' +import React from 'react' + + +type Props = { + tags: string[] + onChange: (tags: string[]) => void +} + +export function TagsInput(props: Props) +{ + const { tags, onChange } = props + + const handleAddTag = (event: React.SyntheticEvent, value: string[]) => + { + onChange(value) + } + + const handleRemoveTag = (tagToRemove: string) => () => + { + const newTags = tags.filter(tag => tag !== tagToRemove) + onChange(newTags) + } + + return ( + + tagValue.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + ) +} diff --git a/src/app/dashboard/posts/page.tsx b/src/app/dashboard/posts/page.tsx new file mode 100644 index 0000000..e319c77 --- /dev/null +++ b/src/app/dashboard/posts/page.tsx @@ -0,0 +1,30 @@ + +import { CreatePostButton } from '@/app/dashboard/posts/_components/CreatePostButton' +import { PostsListView } from '@/app/dashboard/posts/_components/PostsListView' +import { getDatabaseClientAsync } from '@/modules/database/databaseFactory' +import { Stack, Typography } from '@mui/material' + +const getDataAsync = async () => +{ + const client = await getDatabaseClientAsync() + const [posts, projects] = await Promise.all([ + client.getPostsDetailsAsync(), + client.getProjectDetailsAsync() + ]) + return { posts, projects } +} + +export default async function PostsPage() +{ + const { posts, projects } = await getDataAsync() + + return ( + + + Posts + + + + + ) +} diff --git a/src/app/dashboard/projects/_components/ProjectView.tsx b/src/app/dashboard/projects/_components/ProjectsListView.tsx similarity index 98% rename from src/app/dashboard/projects/_components/ProjectView.tsx rename to src/app/dashboard/projects/_components/ProjectsListView.tsx index 0e9630b..c66442b 100644 --- a/src/app/dashboard/projects/_components/ProjectView.tsx +++ b/src/app/dashboard/projects/_components/ProjectsListView.tsx @@ -19,7 +19,7 @@ import { ProjectUpdateValues } from '@/modules/database/requestTypes' import { ProjectDetail } from '@/modules/database/responseTypes' import { useCallback, useMemo, useState } from 'react' -export function ProjectView(props: { projects: ProjectDetail[] }) +export function ProjectsListView(props: { projects: ProjectDetail[] }) { const [projects, setProjects] = useState(props.projects) const [activeProject, setActiveProject] = useState(projects[0]) diff --git a/src/app/dashboard/projects/page.tsx b/src/app/dashboard/projects/page.tsx index 12dee40..26be4d4 100644 --- a/src/app/dashboard/projects/page.tsx +++ b/src/app/dashboard/projects/page.tsx @@ -1,18 +1,18 @@ import { CreateProjectButton } from '@/app/dashboard/projects/_components/CreateProjectButton' -import { ProjectView } from '@/app/dashboard/projects/_components/ProjectView' +import { ProjectsListView } from '@/app/dashboard/projects/_components/ProjectsListView' import { getDatabaseClientAsync } from '@/modules/database/databaseFactory' import { Stack, Typography } from '@mui/material' -const getProjectsAsync = async () => +const getDataAsync = async () => { const client = await getDatabaseClientAsync() - const projects = await client.getProjectsAsync() + const projects = await client.getProjectDetailsAsync() return projects } export default async function ProjectsPage() { - const projects = await getProjectsAsync() + const projects = await getDataAsync() return ( @@ -20,7 +20,7 @@ export default async function ProjectsPage() Projects - +
) } diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx new file mode 100644 index 0000000..052c0c5 --- /dev/null +++ b/src/components/DatePicker.tsx @@ -0,0 +1,29 @@ +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon' +import { DatePicker as MuiDatePicker } from '@mui/x-date-pickers/DatePicker' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import { DateTime } from 'luxon' + +type Props = { + label?: string + value: Date + onChange: (date: Date) => void +} + +export default function DatePicker(props: Props) +{ + const { label, value, onChange } = props + + const date = DateTime.fromJSDate(value) + + function onChangeHandler(value: DateTime | DateTime | null) + { + if (!value || !value.isValid) return + onChange(value.toJSDate()) + } + + return ( + + + + ) +} diff --git a/src/components/ImageBox.tsx b/src/components/ImageBox.tsx index 9705720..266a5e6 100644 --- a/src/components/ImageBox.tsx +++ b/src/components/ImageBox.tsx @@ -1,13 +1,13 @@ 'use client' import { AspectRatioBox } from '@/components/AspectRatioBox' -import { Skeleton } from '@mui/material' +import { Box, Skeleton } from '@mui/material' import { CSSProperties, createRef, useEffect, useState } from 'react' type ImageBoxProps = { src: string - alt: string - aspectRatio: string + alt?: string + aspectRatio?: string borderRadius?: string backgroundColor?: string backgroundImageFill?: boolean @@ -15,7 +15,7 @@ type ImageBoxProps = { export function ImageBox(props: ImageBoxProps) { - const { src, alt, aspectRatio, borderRadius, backgroundColor, backgroundImageFill } = props + const { src, alt = '', aspectRatio, borderRadius, backgroundColor, backgroundImageFill } = props const imageFef = createRef() @@ -28,26 +28,43 @@ export function ImageBox(props: ImageBoxProps) display: loading ? 'none' : 'block', } + const boxStyle: CSSProperties = { + backgroundColor: backgroundColor, + background: backgroundImageFill && !loading ? `url(${src}) center/cover` : undefined, + borderRadius: borderRadius, + width: '100%', + height: '100%', + } + useEffect(() => { if (imageFef.current?.complete) setLoading(false) }, [imageFef]) + if (aspectRatio) + { + return ( + + + <> + {loading && ( + + )} + {/* eslint-disable-next-line @next/next/no-img-element */} + setLoading(false)} src={src} alt={alt} style={imgStyle} /> + + + + ) + } + return ( - - <> - {loading && ( - - )} - {/* eslint-disable-next-line @next/next/no-img-element */} - setLoading(false)} src={src} alt={alt} style={imgStyle} /> - - - + + {loading && ( + + )} + {/* eslint-disable-next-line @next/next/no-img-element */} + setLoading(false)} src={src} alt={alt} style={imgStyle} /> + ) } diff --git a/src/components/MetaDataEditor.tsx b/src/components/MetaDataEditor.tsx index 9e6c9f1..de176ed 100644 --- a/src/components/MetaDataEditor.tsx +++ b/src/components/MetaDataEditor.tsx @@ -149,6 +149,7 @@ export function MetaDataEditor(props: Props) size="small" value={newKeyTemp} onChange={(e) => setNewKeyTemp(e.currentTarget.value)} + onKeyUp={(e) => e.key === 'Enter' && addKeyHandler()} /> diff --git a/src/modules/database/databaseClient.ts b/src/modules/database/databaseClient.ts index a30cc9b..ff244b4 100644 --- a/src/modules/database/databaseClient.ts +++ b/src/modules/database/databaseClient.ts @@ -1,22 +1,32 @@ -import { NewDataFile, ProjectUpdateValues } from '@/modules/database/requestTypes' -import { AccessTokenDetail, DataFileDetails, DataFilesPaginatedResponse, ProjectDetail } from '@/modules/database/responseTypes' +import { NewDataFile, PostUpdateBlockValues, PostUpdateDetailsValues, ProjectUpdateValues } from '@/modules/database/requestTypes' +import { AccessTokenDetail, DataFileDetails, DataFilesPaginatedResponse, PostBlocks, PostDetail, ProjectDetail, ProjectListDetail } from '@/modules/database/responseTypes' -export interface DatabaseClient { +export interface DatabaseClient +{ // Project - getProjectsAsync(): Promise; - createProjectAsync(name: string, isActive: boolean): Promise; - deleteProjectAsync(projectId: string): Promise; - updateProjectAsync(projectId: string, values: ProjectUpdateValues): Promise; + getProjectDetailsAsync(): Promise + getProjectListDetailsAsync(): 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; + createAccessTokenAsync(projectId: string): Promise + deleteAccessTokenAsync(tokenId: string): Promise // DataFile - getDataFileDetailsAsync(fileId: string): Promise; - getDataFileDataAsync(fileId: string): Promise; - getDataFileThumbnailAsync(fileId: string): Promise; - createDataFileAsync(file: NewDataFile): Promise; - deleteDataFileAsync(fileId: string): Promise; + getDataFileDetailsAsync(fileId: string): Promise + getDataFileDataAsync(fileId: string): Promise + getDataFileThumbnailAsync(fileId: string): Promise + createDataFileAsync(file: NewDataFile): Promise + deleteDataFileAsync(fileId: string): Promise getDataFilesPaginatedAsync(skip: number, take: number): Promise + + // Posts + getPostsDetailsAsync(projectId?: string): Promise + getPostBlocksAsync(postId: string): Promise + createPostAsync(title: string, projectId: string): Promise + deletePostAsync(postId: string): Promise + updatePostDetailsAsync(postId: string, values: PostUpdateDetailsValues): Promise + updatePostBlocksAsync(postId: string, values: PostUpdateBlockValues): Promise } diff --git a/src/modules/database/models.ts b/src/modules/database/models.ts index 75ecc0b..1d848a5 100644 --- a/src/modules/database/models.ts +++ b/src/modules/database/models.ts @@ -18,8 +18,8 @@ export type PostModel = { id: string idProject: string title: string - description: string - featuredImageURL: string + description: string | null + featuredImageURL: string | null date: Date blocks: Record> meta: Record diff --git a/src/modules/database/requestTypes.ts b/src/modules/database/requestTypes.ts index 6fc8d85..6fe1170 100644 --- a/src/modules/database/requestTypes.ts +++ b/src/modules/database/requestTypes.ts @@ -1,8 +1,8 @@ -import { FileModel, ProjectModel } from '@/modules/database/models' +import { FileModel, PostModel, ProjectModel } from '@/modules/database/models' export type ProjectUpdateValues = { [K in keyof Pick]?: ProjectModel[K] -}; +} export type NewDataFile = Pick + +export type PostUpdateDetailsValues = { + [K in keyof Pick]?: PostModel[K] +} + +export type PostUpdateBlockValues = Pick diff --git a/src/modules/database/responseTypes.ts b/src/modules/database/responseTypes.ts index 6fa04aa..b07d303 100644 --- a/src/modules/database/responseTypes.ts +++ b/src/modules/database/responseTypes.ts @@ -1,14 +1,16 @@ -import { AccessTokenModel, FileModel, ProjectModel } from '@/modules/database/models' +import { AccessTokenModel, FileModel, PostModel, ProjectModel } from '@/modules/database/models' export type ProjectDetail = Pick & { - accessTokens: Pick[]; -}; + accessTokens: Pick[] +} + +export type ProjectListDetail = Pick export type AccessTokenDetail = Pick; +> export type DataFileDetails = Pick; +> export type DataFileSearchItem = Pick @@ -31,3 +33,27 @@ export type DataFilesPaginatedResponse = { take: number } } + +export type PostDetail = Pick + +export type PostBlocks = Pick + +export type PostUpdate = Pick diff --git a/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts b/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts index 2f9efe8..525303c 100644 --- a/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts +++ b/src/modules/database/vendors/prisma-cockroach/prismaCockroachDatabaseClient.ts @@ -1,9 +1,10 @@ import { DatabaseClient } from '@/modules/database/databaseClient' -import { NewDataFile, ProjectUpdateValues } from '@/modules/database/requestTypes' -import { AccessTokenDetail, DataFileDetails, DataFilesPaginatedResponse, ProjectDetail } from '@/modules/database/responseTypes' +import { NewDataFile, PostUpdateBlockValues, PostUpdateDetailsValues, ProjectUpdateValues } from '@/modules/database/requestTypes' +import { AccessTokenDetail, DataFileDetails, DataFilesPaginatedResponse, PostBlocks, PostDetail, ProjectDetail, ProjectListDetail } from '@/modules/database/responseTypes' import { Prisma, PrismaClient } from '@prisma/client' import { DateTime } from 'luxon' +// #region Selectors const projectDetailSelect = { id: true, name: true, @@ -17,6 +18,18 @@ const projectDetailSelect = { } } +const postDetailSelect = { + id: true, + title: true, + description: true, + featuredImageURL: true, + date: true, + meta: true, + tags: true, + status: true +} +// #endregion + export class PrismaCockroachDatabaseClient implements DatabaseClient { private prisma: PrismaClient @@ -26,14 +39,27 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient this.prisma = new PrismaClient() } - // #region Project - async getProjectsAsync(): Promise + // #region Project Methods + async getProjectDetailsAsync(): Promise { const projects = await this.prisma.project.findMany({ select: projectDetailSelect }) - return projects.map(x => ({...x, meta: this.getPrismaJsonValue(x.meta)})) + return projects.map(x => ({ ...x, meta: this.getPrismaJsonValue(x.meta) })) + } + + async getProjectListDetailsAsync(): Promise + { + const projects = await this.prisma.project.findMany({ + select: { + id: true, + name: true, + active: true + } + }) + + return projects } async createProjectAsync(name: string, isActive: boolean): Promise @@ -61,7 +87,7 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient select: projectDetailSelect }) - return {...project, meta: this.getPrismaJsonValue(project.meta)} + return { ...project, meta: this.getPrismaJsonValue(project.meta) } } async deleteProjectAsync(projectId: string): Promise @@ -85,11 +111,11 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient select: projectDetailSelect }) - return {...project, meta: this.getPrismaJsonValue(project.meta)} + return { ...project, meta: this.getPrismaJsonValue(project.meta) } } // #endregion - // #region Token + // #region Token Methods async createAccessTokenAsync(projectId: string): Promise { const token = await this.prisma.accessToken.create({ @@ -107,7 +133,7 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient } }) - return {...token, idProject: token.idProject!} + return { ...token, idProject: token.idProject! } } async deleteAccessTokenAsync(tokenId: string): Promise @@ -120,7 +146,7 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient } // #endregion - // #region DataFile + // #region DataFile Methods async getDataFileDetailsAsync(fileId: string): Promise { const dataFile = await this.prisma.file.findUnique({ @@ -147,7 +173,7 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient return { id: dataFile.id, name: dataFile.name, - date: DateTime.fromMillis(Number(dataFile.date), {zone: 'utc'}).toJSDate(), + date: DateTime.fromMillis(Number(dataFile.date), { zone: 'utc' }).toJSDate(), mimeType: dataFile.mimeType, extension: dataFile.extension, sizeKb: dataFile.sizeKb, @@ -240,7 +266,7 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient return { id: dataFile.id, name: dataFile.name, - date: DateTime.fromMillis(Number(dataFile.date), {zone: 'utc'}).toJSDate(), + date: this.getDateFromBigint(dataFile.date), mimeType: dataFile.mimeType, extension: dataFile.extension, sizeKb: dataFile.sizeKb, @@ -259,8 +285,139 @@ export class PrismaCockroachDatabaseClient implements DatabaseClient } // #endregion + // #region Posts Methods + async getPostsDetailsAsync(projectId?: string): Promise + { + const posts = await this.prisma.post.findMany({ + select: postDetailSelect, + where: !projectId ? {} : { + idProject: { + equals: projectId + } + } + }) + + return posts.map(x => ({ + ...x, + meta: this.getPrismaJsonValue(x.meta), + date: this.getDateFromBigint(x.date), + tags: this.getPrismaJsonValue(x.tags) + })) + } + + async getPostBlocksAsync(postId: string): Promise + { + const post = await this.prisma.post.findUnique({ + where: { + id: postId + }, + select: { + id: true, + blocks: true + } + }) + + if (!post) + { + throw new Error(`Post with id "${postId}" not found.`) + } + + return { + ...post, + blocks: this.getPrismaJsonValue>>(post.blocks) + } + } + + async createPostAsync(title: string, projectId: string): Promise + { + const post = await this.prisma.post.create({ + data: { + title, + idProject: projectId, + date: DateTime.utc().toMillis(), + meta: {}, + tags: [], + status: 'DISABLED', + blocks: {} + }, + select: postDetailSelect + }) + + return { + ...post, + meta: this.getPrismaJsonValue(post.meta), + date: this.getDateFromBigint(post.date), + tags: this.getPrismaJsonValue(post.tags) + } + } + + async deletePostAsync(postId: string): Promise + { + await this.prisma.post.delete({ + where: { + id: postId + } + }) + } + + async updatePostDetailsAsync(postId: string, values: PostUpdateDetailsValues): Promise + { + const post = await this.prisma.post.update({ + where: { + id: postId + }, + data: { + ...values, + date: values.date ? this.getBigintFromDate(values.date) : undefined, + }, + select: postDetailSelect + }) + + return { + ...post, + meta: this.getPrismaJsonValue(post.meta), + date: this.getDateFromBigint(post.date), + tags: this.getPrismaJsonValue(post.tags) + } + } + + async updatePostBlocksAsync(postId: string, values: PostUpdateBlockValues): Promise + { + const post = await this.prisma.post.update({ + where: { + id: postId + }, + data: { + blocks: values.blocks + }, + select: { + id: true, + blocks: true + } + }) + + return { + ...post, + blocks: this.getPrismaJsonValue>>(post.blocks) + } + + } + // #endregion + + // #region Private Methods + private getBigintFromDate(date: Date): bigint + { + return BigInt(DateTime.fromJSDate(date, { zone: 'utc' }).toMillis()) + } + + private getDateFromBigint(date: bigint): Date + { + return DateTime.fromMillis(Number(date), { zone: 'utc' }).toJSDate() + } + private getPrismaJsonValue(jsonValue: Prisma.JsonValue) { return (jsonValue?.valueOf() ?? {}) as T } + // #endregion }