mirror of
https://github.com/khairul169/code-share.git
synced 2026-10-24 11:53:58 +07:00
feat: add personal project list, add project settings, etc
This commit is contained in:
+2
-14
@@ -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<Data>();
|
||||
@@ -35,20 +36,7 @@ const HomePage = () => {
|
||||
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
||||
{projects.map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/${project.slug}`}
|
||||
className="border border-white/20 hover:border-white/40 rounded-lg transition-colors overflow-hidden"
|
||||
>
|
||||
<div className="w-full aspect-[3/2] bg-gray-900"></div>
|
||||
<div className="py-2 px-3 flex items-center gap-3">
|
||||
<div className="size-8 rounded-full bg-white/80"></div>
|
||||
<div>
|
||||
<p className="text-md truncate">{project.title}</p>
|
||||
<p className="text-xs truncate">{project.user.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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<typeof schema> = {
|
||||
forkFromId: 0,
|
||||
title: "",
|
||||
};
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
const GetStartedPage = () => {
|
||||
const { presets } = useData<Data>();
|
||||
const { presets, forkFrom } = useData<Data>();
|
||||
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 (
|
||||
<div className="container max-w-3xl min-h-[80dvh] flex flex-col items-center justify-center py-8 md:py-16">
|
||||
<Card className="w-full md:p-8">
|
||||
<CardTitle>Create New Project</CardTitle>
|
||||
<CardTitle>{(forkFrom ? "Fork" : "Create New") + " Project"}</CardTitle>
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<FormLabel>Select Preset</FormLabel>
|
||||
{!forkFrom ? (
|
||||
<>
|
||||
<FormLabel>Select Preset</FormLabel>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="forkFromId"
|
||||
render={({ field }) => (
|
||||
<div className="flex md:grid md:grid-cols-3 gap-4 overflow-x-auto md:overflow-x-hidden">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset.projectId}
|
||||
variant={
|
||||
field.value === preset.projectId ? "default" : "outline"
|
||||
}
|
||||
className="flex py-16 border border-white/40 shrink-0 w-[160px] md:w-auto"
|
||||
onClick={() => field.onChange(preset.projectId)}
|
||||
>
|
||||
<p className="text-wrap">{preset.title}</p>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="forkFromId"
|
||||
render={({ field }) => (
|
||||
<div className="flex md:grid md:grid-cols-3 gap-4 overflow-x-auto md:overflow-x-hidden">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset.projectId}
|
||||
variant={
|
||||
field.value === preset.projectId
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className="flex py-16 border border-white/40 shrink-0 w-[160px] md:w-auto"
|
||||
onClick={() => field.onChange(preset.projectId)}
|
||||
>
|
||||
<p className="text-wrap">{preset.title}</p>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
form={form}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { PageContext } from "vike/types";
|
||||
import trpcServer from "~/server/api/trpc/trpc";
|
||||
import db from "~/server/db";
|
||||
import { ProjectSchema, project } from "~/server/db/schema/project";
|
||||
import { UserSchema } from "~/server/db/schema/user";
|
||||
|
||||
export const data = async (ctx: PageContext) => {
|
||||
const trpc = await trpcServer(ctx);
|
||||
let forkFrom:
|
||||
| (Pick<ProjectSchema, "id" | "title"> & { 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<ReturnType<typeof data>>;
|
||||
|
||||
@@ -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 (
|
||||
<main>
|
||||
<section id="browse" className="container max-w-5xl py-8 md:py-16">
|
||||
<h2 className="text-4xl font-medium border-b pb-3 mb-8 border-white/20">
|
||||
My Projects
|
||||
</h2>
|
||||
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
||||
{isLoading
|
||||
? [...Array(6)].map((_, idx) => <ProjectCard.Skeleton key={idx} />)
|
||||
: null}
|
||||
|
||||
{projects?.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
@@ -15,8 +15,9 @@ import { Data } from "./+data";
|
||||
const ViewProjectPage = () => {
|
||||
const { project } = useData<Data>();
|
||||
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 (
|
||||
|
||||
@@ -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<Data>();
|
||||
@@ -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: <FileIcon file={{ isDirectory: false, filename }} />,
|
||||
render: () => <FileViewer id={fileId} />,
|
||||
};
|
||||
})
|
||||
@@ -180,6 +184,7 @@ const Editor = () => {
|
||||
current={Math.min(Math.max(curTabIdx, 0), tabs.length - 1)}
|
||||
onChange={setCurTabIdx}
|
||||
onClose={onCloseFile}
|
||||
className="h-full"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -203,6 +208,8 @@ const Editor = () => {
|
||||
|
||||
<StatusBar className="order-1 md:order-2" />
|
||||
</PanelComponent>
|
||||
|
||||
<SettingsDialog />
|
||||
</EditorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<Data>();
|
||||
@@ -59,7 +60,9 @@ const FileListing = () => {
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem>Upload File</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Project Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => settingsDialog.setState(true)}>
|
||||
Project Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>Download Project</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -184,13 +187,18 @@ const FileItem = ({ file, createFileDlg }: FileItemProps) => {
|
||||
Copy Path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => copy(getPreviewUrl(project, file))}
|
||||
onClick={() =>
|
||||
copy(getPreviewUrl(project, file, { raw: true }))
|
||||
}
|
||||
>
|
||||
Copy URL
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
window.open(getPreviewUrl(project, file), "_blank")
|
||||
window.open(
|
||||
getPreviewUrl(project, file, { raw: true }),
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
>
|
||||
Open in new tab
|
||||
|
||||
@@ -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: () => <GeneralTab form={form} />,
|
||||
},
|
||||
{
|
||||
title: "CSS",
|
||||
render: () => <CSSTab form={form} />,
|
||||
},
|
||||
{
|
||||
title: "Javascript",
|
||||
render: () => <JSTab form={form} />,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={settingsDialog.setState}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Project Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} method="post">
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
current={tab}
|
||||
onChange={setTab}
|
||||
containerClassName="mt-4"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center sm:justify-end gap-4 mt-8">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={save.isPending}>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
type TabProps = {
|
||||
form: useFormReturn<ProjectSettingsSchema>;
|
||||
};
|
||||
|
||||
const GeneralTab = ({ form }: TabProps) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Input form={form} name="title" label="Title" />
|
||||
{/* <Input form={form} name="slug" label="Slug" /> */}
|
||||
<Select
|
||||
form={form}
|
||||
name="visibility"
|
||||
label="Visibility"
|
||||
items={visibilityList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CSSTab = ({ form }: TabProps) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
form={form}
|
||||
name="settings.css.preprocessor"
|
||||
label="Preprocessor"
|
||||
items={cssPreprocessorList}
|
||||
/>
|
||||
<Checkbox
|
||||
form={form}
|
||||
name="settings.css.tailwindcss"
|
||||
label="Tailwindcss"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const JSTab = ({ form }: TabProps) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
form={form}
|
||||
name="settings.js.transpiler"
|
||||
label="Transpiler"
|
||||
items={jsTranspilerList}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
* Set transpiler to <strong>SWC</strong> to use JSX
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDialog;
|
||||
@@ -1,5 +1,11 @@
|
||||
import React from "react";
|
||||
import { FiArrowLeft, FiSidebar, FiSmartphone, FiUser } from "react-icons/fi";
|
||||
import {
|
||||
FiArrowLeft,
|
||||
FiGitPullRequest,
|
||||
FiSidebar,
|
||||
FiSmartphone,
|
||||
FiUser,
|
||||
} from "react-icons/fi";
|
||||
import { useStore } from "zustand";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
@@ -11,7 +17,7 @@ import { useProjectContext } from "../context/project";
|
||||
|
||||
const StatusBar = ({ className }: React.ComponentProps<"div">) => {
|
||||
const { user, urlPathname } = usePageContext();
|
||||
const { isCompact } = useProjectContext();
|
||||
const { isCompact, project } = useProjectContext();
|
||||
const sidebarExpanded = useStore(sidebarStore, (i) => i.expanded);
|
||||
const previewExpanded = useStore(previewStore, (i) => i.open);
|
||||
|
||||
@@ -22,24 +28,10 @@ const StatusBar = ({ className }: React.ComponentProps<"div">) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 flex items-center gap-1 pl-2 pr-3 w-full bg-slate-800 md:bg-[#242424] md:border-t border-slate-900 md:border-black/30",
|
||||
"h-10 flex items-center gap-1 pr-3 w-full bg-slate-800 md:bg-[#242424] md:border-t border-slate-900 md:border-black/30",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ActionButton
|
||||
title="Toggle Sidebar (CTRL+B)"
|
||||
icon={FiSidebar}
|
||||
className={sidebarExpanded ? "text-white" : ""}
|
||||
onClick={() => sidebarStore.getState().toggle()}
|
||||
/>
|
||||
<ActionButton
|
||||
title="Toggle Preview Window (CTRL+P)"
|
||||
icon={FiSmartphone}
|
||||
className={previewExpanded ? "text-white" : ""}
|
||||
onClick={() => previewStore.getState().toggle()}
|
||||
/>
|
||||
|
||||
<div className="flex-1"></div>
|
||||
<ActionButton
|
||||
title="Return to Home"
|
||||
href="/"
|
||||
@@ -54,6 +46,28 @@ const StatusBar = ({ className }: React.ComponentProps<"div">) => {
|
||||
<FiUser className="text-sm" />
|
||||
{user?.name || "Log in"}
|
||||
</Button>
|
||||
<Button
|
||||
href={"/get-started?fork=" + project.slug}
|
||||
className="h-full p-0 gap-2 text-xs ml-4 truncate"
|
||||
variant="link"
|
||||
>
|
||||
<FiGitPullRequest className="text-sm" />
|
||||
<span className="truncate">Fork Project</span>
|
||||
</Button>
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<ActionButton
|
||||
title="Toggle Sidebar (CTRL+B)"
|
||||
icon={FiSidebar}
|
||||
className={sidebarExpanded ? "text-white" : ""}
|
||||
onClick={() => sidebarStore.getState().toggle()}
|
||||
/>
|
||||
<ActionButton
|
||||
title="Toggle Preview Window (CTRL+P)"
|
||||
icon={FiSmartphone}
|
||||
className={previewExpanded ? "text-white" : ""}
|
||||
onClick={() => previewStore.getState().toggle()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ucfirst } from "~/lib/utils";
|
||||
|
||||
export const visibilityList = ["public", "private", "unlisted"].map(
|
||||
(value) => ({
|
||||
value,
|
||||
label: ucfirst(value),
|
||||
})
|
||||
);
|
||||
|
||||
export const cssPreprocessorList = [
|
||||
{
|
||||
label: "None",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
label: "PostCSS",
|
||||
value: "postcss",
|
||||
},
|
||||
];
|
||||
|
||||
export const jsTranspilerList = [
|
||||
{
|
||||
label: "None",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
label: "SWC",
|
||||
value: "swc",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const projectSettingsSchema = z
|
||||
.object({
|
||||
title: z.string().min(6),
|
||||
// slug: z.string().min(8),
|
||||
visibility: z.enum(["public", "private", "unlisted"]),
|
||||
|
||||
settings: z.object({
|
||||
css: z.object({
|
||||
preprocessor: z.enum(["", "postcss"]).optional().nullable(),
|
||||
tailwindcss: z.boolean().optional().nullable(),
|
||||
}),
|
||||
|
||||
js: z.object({
|
||||
transpiler: z.enum(["", "swc"]).optional().nullable(),
|
||||
packages: z
|
||||
.object({ name: z.string().min(1), url: z.string().min(1).url() })
|
||||
.array(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
const { settings } = val;
|
||||
|
||||
if (settings.css.tailwindcss && settings.css.preprocessor !== "postcss") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Preprocessor need to be set to PostCSS to use tailwindcss.",
|
||||
path: ["settings.css.preprocessor"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ProjectSettingsSchema = z.infer<typeof projectSettingsSchema>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createStore } from "zustand";
|
||||
|
||||
export const settingsDialog = createStore<boolean>(() => false);
|
||||
Reference in New Issue
Block a user