From 751210c819de41a924cd0c1e3c7950e809de8db1 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Sat, 24 Feb 2024 03:55:50 +0000 Subject: [PATCH] feat: add personal project list, add project settings, etc --- components/containers/navbar.tsx | 5 +- components/containers/project-card.tsx | 46 +++++ components/ui/checkbox.tsx | 82 ++++++++ components/ui/dialog.tsx | 2 +- components/ui/input.tsx | 4 +- components/ui/select.tsx | 88 +++++++++ components/ui/skeleton.tsx | 15 ++ components/ui/tabs.tsx | 36 ++-- lib/utils.ts | 6 +- pages/index/+Page.tsx | 16 +- pages/index/get-started/+Page.tsx | 66 ++++--- pages/index/get-started/+data.ts | 21 ++- pages/index/projects/+Page.tsx | 31 ++++ pages/project/@slug/+Page.tsx | 5 +- pages/project/@slug/components/editor.tsx | 9 +- .../project/@slug/components/file-listing.tsx | 14 +- .../@slug/components/settings-dialog.tsx | 175 ++++++++++++++++++ pages/project/@slug/components/status-bar.tsx | 48 +++-- pages/project/@slug/lib/consts.ts | 30 +++ pages/project/@slug/lib/schema.ts | 35 ++++ pages/project/@slug/stores/dialogs.ts | 3 + renderer/link.tsx | 25 ++- server/api/preview/index.ts | 19 +- server/api/preview/postcss.ts | 17 +- server/api/preview/serve-html.ts | 15 +- server/api/preview/serve-js.ts | 12 +- server/api/trpc/context.ts | 2 +- server/db/schema/project.ts | 76 ++++++-- server/db/seed.ts | 30 ++- server/lib/unpack-project.ts | 1 - server/routers/project.ts | 91 +++++++-- 31 files changed, 877 insertions(+), 148 deletions(-) create mode 100644 components/containers/project-card.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 pages/index/projects/+Page.tsx create mode 100644 pages/project/@slug/components/settings-dialog.tsx create mode 100644 pages/project/@slug/lib/consts.ts create mode 100644 pages/project/@slug/lib/schema.ts create mode 100644 pages/project/@slug/stores/dialogs.ts diff --git a/components/containers/navbar.tsx b/components/containers/navbar.tsx index 22a88af..48d9a28 100644 --- a/components/containers/navbar.tsx +++ b/components/containers/navbar.tsx @@ -15,7 +15,7 @@ import trpc from "~/lib/trpc"; import { usePageContext } from "~/renderer/context"; const Navbar = () => { - const {user, urlPathname } = usePageContext(); + const { user, urlPathname } = usePageContext(); const logout = trpc.auth.logout.useMutation({ onSuccess() { window.location.reload(); @@ -46,6 +46,9 @@ const Navbar = () => { + + My Projects + logout.mutate()}> Logout diff --git a/components/containers/project-card.tsx b/components/containers/project-card.tsx new file mode 100644 index 0000000..f52e0bb --- /dev/null +++ b/components/containers/project-card.tsx @@ -0,0 +1,46 @@ +import Link from "~/renderer/link"; +import type { ProjectSchema } from "~/server/db/schema/project"; +import type { UserSchema } from "~/server/db/schema/user"; +import { Skeleton } from "../ui/skeleton"; + +type Props = { + project: Omit & { + user: UserSchema; + }; +}; + +const ProjectCard = ({ project }: Props) => { + return ( + +
+
+
+
+

{project.title}

+

{project.user.name}

+
+
+ + ); +}; + +const ProjectCardSkeleton = () => ( +
+ +
+ +
+ + +
+
+
+); + +ProjectCard.Skeleton = ProjectCardSkeleton; + +export default ProjectCard; diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 0000000..d5bd87f --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,82 @@ +import React, { forwardRef, useId } from "react"; +import FormField, { FormFieldProps } from "./form-field"; +import { cn } from "~/lib/utils"; +import { Controller, FieldValues, Path } from "react-hook-form"; +import { useFormReturn } from "~/hooks/useForm"; + +export type CheckboxItem = { + label: string; + value: string; +}; + +type BaseCheckboxProps = React.ComponentPropsWithoutRef<"input"> & + FormFieldProps & { + inputClassName?: string; + items?: CheckboxItem[] | null; + }; + +const BaseCheckbox = forwardRef( + (props, ref) => { + const { className, label, error, inputClassName, items, ...restProps } = + props; + const id = useId(); + + const input = ( + + ); + + if (error) { + return ; + } + + return input; + } +); + +type CheckboxProps = Omit< + BaseCheckboxProps, + "form" | "name" +> & { + form?: useFormReturn; + name?: Path; +}; + +const Checkbox = (props: CheckboxProps) => { + const { form, ...restProps } = props; + + if (form && props.name) { + return ( + ( + + )} + /> + ); + } + + return ; +}; + +export default Checkbox; diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index b1fae9b..33bc5b4 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -36,7 +36,7 @@ const DialogContent = React.forwardRef< ( & + FormFieldProps & { + inputClassName?: string; + items?: SelectItem[] | null; + }; + +const BaseSelect = forwardRef( + (props, ref) => { + const { className, label, error, inputClassName, items, ...restProps } = + props; + const id = useId(); + + const input = ( + + ); + + if (label || error) { + return ( + + ); + } + + return input; + } +); + +type SelectProps = Omit< + BaseSelectProps, + "form" | "name" +> & { + form?: useFormReturn; + name?: Path; +}; + +const Select = (props: SelectProps) => { + const { form, ...restProps } = props; + + if (form && props.name) { + return ( + ( + + )} + /> + ); + } + + return ; +}; + +export default Select; diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx new file mode 100644 index 0000000..ade6893 --- /dev/null +++ b/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "~/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx index f2a7866..f11d685 100644 --- a/components/ui/tabs.tsx +++ b/components/ui/tabs.tsx @@ -2,7 +2,6 @@ import { cn } from "~/lib/utils"; import React, { useEffect, useMemo, useRef } from "react"; import ActionButton from "./action-button"; import { FiX } from "react-icons/fi"; -import FileIcon from "./file-icon"; export type Tab = { title: string; @@ -16,9 +15,18 @@ type Props = { current?: number; onChange?: (idx: number) => void; onClose?: (idx: number) => void; + className?: string; + containerClassName?: string; }; -const Tabs = ({ tabs, current = 0, onChange, onClose }: Props) => { +const Tabs = ({ + tabs, + current = 0, + onChange, + onClose, + className, + containerClassName, +}: Props) => { const tabContainerRef = useRef(null); const onWheel = (e: WheelEvent) => { @@ -63,7 +71,12 @@ const Tabs = ({ tabs, current = 0, onChange, onClose }: Props) => { }, [tabs, current]); return ( -
+
{tabs.length > 0 ? ( ) : null} -
{tabView}
+
+ {tabView} +
); }; @@ -119,17 +134,16 @@ const TabItem = ({ onClick={onSelect} > diff --git a/lib/utils.ts b/lib/utils.ts index 87eacc9..b4fcf0b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -20,12 +20,14 @@ export function getUrl(...path: string[]) { export function getPreviewUrl( project: Pick, - file: string | Pick + file: string | Pick, + opt?: Partial<{ raw: boolean }> ) { - return getUrl( + const url = getUrl( `api/preview/${project.slug}`, typeof file === "string" ? file : file.path ); + return opt?.raw ? url + "?raw=true" : url; } export const ucfirst = (str: string) => { diff --git a/pages/index/+Page.tsx b/pages/index/+Page.tsx index 85b3968..6cb6323 100644 --- a/pages/index/+Page.tsx +++ b/pages/index/+Page.tsx @@ -3,6 +3,7 @@ import type { Data } from "./+data"; import { useData } from "~/renderer/hooks"; import Link from "~/renderer/link"; import Footer from "~/components/containers/footer"; +import ProjectCard from "~/components/containers/project-card"; const HomePage = () => { const { projects } = useData(); @@ -35,20 +36,7 @@ const HomePage = () => {
{projects.map((project) => ( - -
-
-
-
-

{project.title}

-

{project.user.name}

-
-
- + ))}
diff --git a/pages/index/get-started/+Page.tsx b/pages/index/get-started/+Page.tsx index d2b4f2a..447aff0 100644 --- a/pages/index/get-started/+Page.tsx +++ b/pages/index/get-started/+Page.tsx @@ -12,6 +12,7 @@ import Divider from "~/components/ui/divider"; import trpc from "~/lib/trpc"; import { useAuth } from "~/hooks/useAuth"; import { usePageContext } from "~/renderer/context"; +import { useMemo } from "react"; const schema = z.object({ forkFromId: z.number(), @@ -25,16 +26,21 @@ const schema = z.object({ .optional(), }); -const initialValue: z.infer = { - forkFromId: 0, - title: "", -}; +type Schema = z.infer; const GetStartedPage = () => { - const { presets } = useData(); + const { presets, forkFrom } = useData(); const { isLoggedIn } = useAuth(); const ctx = usePageContext(); + const initialValue: Schema = useMemo( + () => ({ + forkFromId: forkFrom?.id || 0, + title: forkFrom?.title || "", + }), + [forkFrom] + ); + const form = useForm(schema, initialValue); const create = trpc.project.create.useMutation({ onSuccess(data) { @@ -49,31 +55,37 @@ const GetStartedPage = () => { return (
- Create New Project + {(forkFrom ? "Fork" : "Create New") + " Project"}
- Select Preset + {!forkFrom ? ( + <> + Select Preset - ( -
- {presets.map((preset) => ( - - ))} -
- )} - /> + ( +
+ {presets.map((preset) => ( + + ))} +
+ )} + /> + + ) : null} { const trpc = await trpcServer(ctx); + let forkFrom: + | (Pick & { user: UserSchema }) + | undefined; + + if (ctx.urlParsed.search["fork"]) { + forkFrom = await db.query.project.findFirst({ + columns: { id: true, title: true }, + where: and( + eq(project.slug, ctx.urlParsed.search["fork"]), + isNull(project.deletedAt) + ), + with: { user: { columns: { password: false } } }, + }); + } + const presets = await trpc.project.getTemplates(); - return { presets }; + return { presets, forkFrom }; }; export type Data = Awaited>; diff --git a/pages/index/projects/+Page.tsx b/pages/index/projects/+Page.tsx new file mode 100644 index 0000000..9e0d0ea --- /dev/null +++ b/pages/index/projects/+Page.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import ProjectCard from "~/components/containers/project-card"; +import trpc from "~/lib/trpc"; + +const ProjectsPage = () => { + const { data: projects, isLoading } = trpc.project.getAll.useQuery({ + owned: true, + }); + + return ( +
+
+

+ My Projects +

+ +
+ {isLoading + ? [...Array(6)].map((_, idx) => ) + : null} + + {projects?.map((project) => ( + + ))} +
+
+
+ ); +}; + +export default ProjectsPage; diff --git a/pages/project/@slug/+Page.tsx b/pages/project/@slug/+Page.tsx index caf5c5e..bdc5462 100644 --- a/pages/project/@slug/+Page.tsx +++ b/pages/project/@slug/+Page.tsx @@ -15,8 +15,9 @@ import { Data } from "./+data"; const ViewProjectPage = () => { const { project } = useData(); const searchParams = useSearchParams(); - const isCompact = - searchParams.get("compact") === "1" || searchParams.get("embed") === "1"; + const isCompact = !!( + searchParams.get("compact") || searchParams.get("embed") + ); const previewUrl = getPreviewUrl(project, "index.html"); return ( diff --git a/pages/project/@slug/components/editor.tsx b/pages/project/@slug/components/editor.tsx index b9c3102..b7c66e6 100644 --- a/pages/project/@slug/components/editor.tsx +++ b/pages/project/@slug/components/editor.tsx @@ -18,6 +18,8 @@ import { Data } from "../+data"; import { useBreakpoint } from "~/hooks/useBreakpoint"; import StatusBar from "./status-bar"; import { FiTerminal } from "react-icons/fi"; +import SettingsDialog from "./settings-dialog"; +import FileIcon from "~/components/ui/file-icon"; const Editor = () => { const { project, initialFiles } = useData(); @@ -125,9 +127,11 @@ const Editor = () => { tabs = tabs.concat( curOpenFiles.map((fileId) => { const fileData = openedFiles?.find((i) => i.id === fileId); + const filename = fileData?.filename || "..."; return { - title: fileData?.filename || "...", + title: filename, + icon: , render: () => , }; }) @@ -180,6 +184,7 @@ const Editor = () => { current={Math.min(Math.max(curTabIdx, 0), tabs.length - 1)} onChange={setCurTabIdx} onClose={onCloseFile} + className="h-full" /> @@ -203,6 +208,8 @@ const Editor = () => { + + ); }; diff --git a/pages/project/@slug/components/file-listing.tsx b/pages/project/@slug/components/file-listing.tsx index dfa23a6..ac80efa 100644 --- a/pages/project/@slug/components/file-listing.tsx +++ b/pages/project/@slug/components/file-listing.tsx @@ -24,6 +24,7 @@ import FileIcon from "~/components/ui/file-icon"; import { useData } from "~/renderer/hooks"; import Spinner from "~/components/ui/spinner"; import { Data } from "../+data"; +import { settingsDialog } from "../stores/dialogs"; const FileListing = () => { const { project, files: initialFiles } = useData(); @@ -59,7 +60,9 @@ const FileListing = () => { Upload File - Project Settings + settingsDialog.setState(true)}> + Project Settings + Download Project @@ -184,13 +187,18 @@ const FileItem = ({ file, createFileDlg }: FileItemProps) => { Copy Path copy(getPreviewUrl(project, file))} + onClick={() => + copy(getPreviewUrl(project, file, { raw: true })) + } > Copy URL - window.open(getPreviewUrl(project, file), "_blank") + window.open( + getPreviewUrl(project, file, { raw: true }), + "_blank" + ) } > Open in new tab diff --git a/pages/project/@slug/components/settings-dialog.tsx b/pages/project/@slug/components/settings-dialog.tsx new file mode 100644 index 0000000..54d6732 --- /dev/null +++ b/pages/project/@slug/components/settings-dialog.tsx @@ -0,0 +1,175 @@ +import { useStore } from "zustand"; +import { settingsDialog } from "../stores/dialogs"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { useMemo, useState } from "react"; +import { useProjectContext } from "../context/project"; +import { useForm, useFormReturn } from "~/hooks/useForm"; +import Input from "~/components/ui/input"; +import Select from "~/components/ui/select"; +import { Button } from "~/components/ui/button"; +import { ProjectSettingsSchema, projectSettingsSchema } from "../lib/schema"; +import { + cssPreprocessorList, + jsTranspilerList, + visibilityList, +} from "../lib/consts"; +import trpc from "~/lib/trpc"; +import { toast } from "~/lib/utils"; +import Checkbox from "~/components/ui/checkbox"; +import Tabs, { Tab } from "~/components/ui/tabs"; +import { navigate } from "vike/client/router"; + +const defaultValues: ProjectSettingsSchema = { + title: "", + // slug: "", + visibility: "private", + + settings: { + css: { + preprocessor: null, + tailwindcss: false, + }, + js: { + transpiler: null, + packages: [], + }, + }, +}; + +const SettingsDialog = () => { + const { project } = useProjectContext(); + const [tab, setTab] = useState(0); + const initialValues = useMemo(() => { + return Object.assign(defaultValues, { + title: project.title, + // slug: project.slug, + settings: project.settings, + }); + }, [project]); + + const open = useStore(settingsDialog); + const form = useForm(projectSettingsSchema, initialValues); + const save = trpc.project.update.useMutation({ + onSuccess(data) { + toast.success("Project updated!"); + onClose(); + if (data.slug !== project.slug) { + navigate(`/${data.slug}`); + } + }, + }); + + const onClose = () => { + settingsDialog.setState(false); + }; + + const onSubmit = form.handleSubmit((values) => { + save.mutate({ ...values, id: project.id }); + }); + + const tabs: Tab[] = useMemo( + () => [ + { + title: "General", + render: () => , + }, + { + title: "CSS", + render: () => , + }, + { + title: "Javascript", + render: () => , + }, + ], + [] + ); + + return ( + + + + Project Settings + + + + + +
+ + +
+ +
+
+ ); +}; + +type TabProps = { + form: useFormReturn; +}; + +const GeneralTab = ({ form }: TabProps) => { + return ( +
+ + {/* */} + + +
+ ); +}; + +const JSTab = ({ form }: TabProps) => { + return ( +
+