mirror of
https://github.com/khairul169/code-share.git
synced 2025-04-29 17:19:37 +07:00
feat: add personal project list, add project settings, etc
This commit is contained in:
parent
49b80a2f4c
commit
751210c819
@ -15,7 +15,7 @@ import trpc from "~/lib/trpc";
|
|||||||
import { usePageContext } from "~/renderer/context";
|
import { usePageContext } from "~/renderer/context";
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const {user, urlPathname } = usePageContext();
|
const { user, urlPathname } = usePageContext();
|
||||||
const logout = trpc.auth.logout.useMutation({
|
const logout = trpc.auth.logout.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@ -46,6 +46,9 @@ const Navbar = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/projects">My Projects</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => logout.mutate()}>
|
<DropdownMenuItem onClick={() => logout.mutate()}>
|
||||||
Logout
|
Logout
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
46
components/containers/project-card.tsx
Normal file
46
components/containers/project-card.tsx
Normal file
@ -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<ProjectSchema, "settings"> & {
|
||||||
|
user: UserSchema;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectCard = ({ project }: Props) => {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectCardSkeleton = () => (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="w-full aspect-[3/2]" />
|
||||||
|
<div className="py-2 flex items-center gap-3">
|
||||||
|
<Skeleton className="size-8 rounded-full" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="w-full h-4" />
|
||||||
|
<Skeleton className="w-full h-4 mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
ProjectCard.Skeleton = ProjectCardSkeleton;
|
||||||
|
|
||||||
|
export default ProjectCard;
|
82
components/ui/checkbox.tsx
Normal file
82
components/ui/checkbox.tsx
Normal file
@ -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<HTMLInputElement, BaseCheckboxProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { className, label, error, inputClassName, items, ...restProps } =
|
||||||
|
props;
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
const input = (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 cursor-pointer",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
{...restProps}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
{label ? <span>{label}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <FormField id={id} error={error} children={input} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type CheckboxProps<T extends FieldValues> = Omit<
|
||||||
|
BaseCheckboxProps,
|
||||||
|
"form" | "name"
|
||||||
|
> & {
|
||||||
|
form?: useFormReturn<T>;
|
||||||
|
name?: Path<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Checkbox = <T extends FieldValues>(props: CheckboxProps<T>) => {
|
||||||
|
const { form, ...restProps } = props;
|
||||||
|
|
||||||
|
if (form && props.name) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={props.name}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<BaseCheckbox
|
||||||
|
{...restProps}
|
||||||
|
{...field}
|
||||||
|
checked={field.value}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BaseCheckbox {...restProps} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Checkbox;
|
@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-200 bg-white p-6 md:p-8 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:rounded-xl dark:border-slate-800 dark:bg-slate-950",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg max-h-dvh overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-200 bg-white p-6 md:p-8 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:rounded-xl dark:border-slate-600 dark:bg-slate-800",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ForwardedRef, forwardRef, useId } from "react";
|
import { forwardRef, useId } from "react";
|
||||||
import { Controller, FieldValues, Path } from "react-hook-form";
|
import { Controller, FieldValues, Path } from "react-hook-form";
|
||||||
import { useFormReturn } from "~/hooks/useForm";
|
import { useFormReturn } from "~/hooks/useForm";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
@ -17,7 +17,7 @@ const BaseInput = forwardRef<HTMLInputElement, BaseInputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/40 dark:bg-background dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
|
"flex h-10 w-full rounded-md border border-slate-200 px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/40 bg-transparent dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
|
||||||
inputClassName
|
inputClassName
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
88
components/ui/select.tsx
Normal file
88
components/ui/select.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
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 SelectItem = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseSelectProps = React.ComponentPropsWithoutRef<"select"> &
|
||||||
|
FormFieldProps & {
|
||||||
|
inputClassName?: string;
|
||||||
|
items?: SelectItem[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BaseSelect = forwardRef<HTMLSelectElement, BaseSelectProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { className, label, error, inputClassName, items, ...restProps } =
|
||||||
|
props;
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
const input = (
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-10 w-full rounded-md border border-slate-200 px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/40 bg-transparent dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
|
||||||
|
inputClassName
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{items?.map((item) => (
|
||||||
|
<option key={item.value} value={item.value} className="text-gray-900">
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (label || error) {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
id={id}
|
||||||
|
label={label}
|
||||||
|
error={error}
|
||||||
|
className={className}
|
||||||
|
children={input}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type SelectProps<T extends FieldValues> = Omit<
|
||||||
|
BaseSelectProps,
|
||||||
|
"form" | "name"
|
||||||
|
> & {
|
||||||
|
form?: useFormReturn<T>;
|
||||||
|
name?: Path<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Select = <T extends FieldValues>(props: SelectProps<T>) => {
|
||||||
|
const { form, ...restProps } = props;
|
||||||
|
|
||||||
|
if (form && props.name) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={props.name}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<BaseSelect
|
||||||
|
{...restProps}
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BaseSelect {...restProps} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Select;
|
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-slate-100 dark:bg-slate-800", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
@ -2,7 +2,6 @@ import { cn } from "~/lib/utils";
|
|||||||
import React, { useEffect, useMemo, useRef } from "react";
|
import React, { useEffect, useMemo, useRef } from "react";
|
||||||
import ActionButton from "./action-button";
|
import ActionButton from "./action-button";
|
||||||
import { FiX } from "react-icons/fi";
|
import { FiX } from "react-icons/fi";
|
||||||
import FileIcon from "./file-icon";
|
|
||||||
|
|
||||||
export type Tab = {
|
export type Tab = {
|
||||||
title: string;
|
title: string;
|
||||||
@ -16,9 +15,18 @@ type Props = {
|
|||||||
current?: number;
|
current?: number;
|
||||||
onChange?: (idx: number) => void;
|
onChange?: (idx: number) => void;
|
||||||
onClose?: (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<HTMLDivElement>(null);
|
const tabContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
@ -63,7 +71,12 @@ const Tabs = ({ tabs, current = 0, onChange, onClose }: Props) => {
|
|||||||
}, [tabs, current]);
|
}, [tabs, current]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col items-stretch bg-slate-800">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full flex flex-col items-stretch bg-slate-800",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{tabs.length > 0 ? (
|
{tabs.length > 0 ? (
|
||||||
<nav
|
<nav
|
||||||
ref={tabContainerRef}
|
ref={tabContainerRef}
|
||||||
@ -77,13 +90,15 @@ const Tabs = ({ tabs, current = 0, onChange, onClose }: Props) => {
|
|||||||
icon={tab.icon}
|
icon={tab.icon}
|
||||||
isActive={idx === current}
|
isActive={idx === current}
|
||||||
onSelect={() => onChange && onChange(idx)}
|
onSelect={() => onChange && onChange(idx)}
|
||||||
onClose={!tab.locked ? () => onClose && onClose(idx) : null}
|
onClose={!tab.locked && onClose ? () => onClose(idx) : null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<main className="flex-1 overflow-hidden">{tabView}</main>
|
<main className={cn("flex-1 overflow-hidden", containerClassName)}>
|
||||||
|
{tabView}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -119,17 +134,16 @@ const TabItem = ({
|
|||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"pl-4 pr-4 truncate flex items-center self-stretch",
|
"pl-4 pr-4 truncate flex items-center self-stretch",
|
||||||
onClose ? "pr-0" : ""
|
onClose ? "pr-0" : ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon != null ? (
|
{icon}
|
||||||
icon
|
<span className={cn("inline-block truncate", icon ? "ml-2" : "")}>
|
||||||
) : (
|
{filename}
|
||||||
<FileIcon file={{ isDirectory: false, filename: title }} />
|
</span>
|
||||||
)}
|
|
||||||
<span className="inline-block ml-2 truncate">{filename}</span>
|
|
||||||
<span>{ext}</span>
|
<span>{ext}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@ -20,12 +20,14 @@ export function getUrl(...path: string[]) {
|
|||||||
|
|
||||||
export function getPreviewUrl(
|
export function getPreviewUrl(
|
||||||
project: Pick<ProjectSchema, "slug">,
|
project: Pick<ProjectSchema, "slug">,
|
||||||
file: string | Pick<FileSchema, "path">
|
file: string | Pick<FileSchema, "path">,
|
||||||
|
opt?: Partial<{ raw: boolean }>
|
||||||
) {
|
) {
|
||||||
return getUrl(
|
const url = getUrl(
|
||||||
`api/preview/${project.slug}`,
|
`api/preview/${project.slug}`,
|
||||||
typeof file === "string" ? file : file.path
|
typeof file === "string" ? file : file.path
|
||||||
);
|
);
|
||||||
|
return opt?.raw ? url + "?raw=true" : url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ucfirst = (str: string) => {
|
export const ucfirst = (str: string) => {
|
||||||
|
@ -3,6 +3,7 @@ import type { Data } from "./+data";
|
|||||||
import { useData } from "~/renderer/hooks";
|
import { useData } from "~/renderer/hooks";
|
||||||
import Link from "~/renderer/link";
|
import Link from "~/renderer/link";
|
||||||
import Footer from "~/components/containers/footer";
|
import Footer from "~/components/containers/footer";
|
||||||
|
import ProjectCard from "~/components/containers/project-card";
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { projects } = useData<Data>();
|
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">
|
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Link
|
<ProjectCard key={project.id} project={project} />
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -12,6 +12,7 @@ import Divider from "~/components/ui/divider";
|
|||||||
import trpc from "~/lib/trpc";
|
import trpc from "~/lib/trpc";
|
||||||
import { useAuth } from "~/hooks/useAuth";
|
import { useAuth } from "~/hooks/useAuth";
|
||||||
import { usePageContext } from "~/renderer/context";
|
import { usePageContext } from "~/renderer/context";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
forkFromId: z.number(),
|
forkFromId: z.number(),
|
||||||
@ -25,16 +26,21 @@ const schema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialValue: z.infer<typeof schema> = {
|
type Schema = z.infer<typeof schema>;
|
||||||
forkFromId: 0,
|
|
||||||
title: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const GetStartedPage = () => {
|
const GetStartedPage = () => {
|
||||||
const { presets } = useData<Data>();
|
const { presets, forkFrom } = useData<Data>();
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn } = useAuth();
|
||||||
const ctx = usePageContext();
|
const ctx = usePageContext();
|
||||||
|
|
||||||
|
const initialValue: Schema = useMemo(
|
||||||
|
() => ({
|
||||||
|
forkFromId: forkFrom?.id || 0,
|
||||||
|
title: forkFrom?.title || "",
|
||||||
|
}),
|
||||||
|
[forkFrom]
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm(schema, initialValue);
|
const form = useForm(schema, initialValue);
|
||||||
const create = trpc.project.create.useMutation({
|
const create = trpc.project.create.useMutation({
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
@ -49,31 +55,37 @@ const GetStartedPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="container max-w-3xl min-h-[80dvh] flex flex-col items-center justify-center py-8 md:py-16">
|
<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">
|
<Card className="w-full md:p-8">
|
||||||
<CardTitle>Create New Project</CardTitle>
|
<CardTitle>{(forkFrom ? "Fork" : "Create New") + " Project"}</CardTitle>
|
||||||
|
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<FormLabel>Select Preset</FormLabel>
|
{!forkFrom ? (
|
||||||
|
<>
|
||||||
|
<FormLabel>Select Preset</FormLabel>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="forkFromId"
|
name="forkFromId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="flex md:grid md:grid-cols-3 gap-4 overflow-x-auto md:overflow-x-hidden">
|
<div className="flex md:grid md:grid-cols-3 gap-4 overflow-x-auto md:overflow-x-hidden">
|
||||||
{presets.map((preset) => (
|
{presets.map((preset) => (
|
||||||
<Button
|
<Button
|
||||||
key={preset.projectId}
|
key={preset.projectId}
|
||||||
variant={
|
variant={
|
||||||
field.value === preset.projectId ? "default" : "outline"
|
field.value === preset.projectId
|
||||||
}
|
? "default"
|
||||||
className="flex py-16 border border-white/40 shrink-0 w-[160px] md:w-auto"
|
: "outline"
|
||||||
onClick={() => field.onChange(preset.projectId)}
|
}
|
||||||
>
|
className="flex py-16 border border-white/40 shrink-0 w-[160px] md:w-auto"
|
||||||
<p className="text-wrap">{preset.title}</p>
|
onClick={() => field.onChange(preset.projectId)}
|
||||||
</Button>
|
>
|
||||||
))}
|
<p className="text-wrap">{preset.title}</p>
|
||||||
</div>
|
</Button>
|
||||||
)}
|
))}
|
||||||
/>
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
form={form}
|
form={form}
|
||||||
|
@ -1,11 +1,30 @@
|
|||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { PageContext } from "vike/types";
|
import { PageContext } from "vike/types";
|
||||||
import trpcServer from "~/server/api/trpc/trpc";
|
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) => {
|
export const data = async (ctx: PageContext) => {
|
||||||
const trpc = await trpcServer(ctx);
|
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();
|
const presets = await trpc.project.getTemplates();
|
||||||
|
|
||||||
return { presets };
|
return { presets, forkFrom };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Data = Awaited<ReturnType<typeof data>>;
|
export type Data = Awaited<ReturnType<typeof data>>;
|
||||||
|
31
pages/index/projects/+Page.tsx
Normal file
31
pages/index/projects/+Page.tsx
Normal file
@ -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 ViewProjectPage = () => {
|
||||||
const { project } = useData<Data>();
|
const { project } = useData<Data>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isCompact =
|
const isCompact = !!(
|
||||||
searchParams.get("compact") === "1" || searchParams.get("embed") === "1";
|
searchParams.get("compact") || searchParams.get("embed")
|
||||||
|
);
|
||||||
const previewUrl = getPreviewUrl(project, "index.html");
|
const previewUrl = getPreviewUrl(project, "index.html");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -18,6 +18,8 @@ import { Data } from "../+data";
|
|||||||
import { useBreakpoint } from "~/hooks/useBreakpoint";
|
import { useBreakpoint } from "~/hooks/useBreakpoint";
|
||||||
import StatusBar from "./status-bar";
|
import StatusBar from "./status-bar";
|
||||||
import { FiTerminal } from "react-icons/fi";
|
import { FiTerminal } from "react-icons/fi";
|
||||||
|
import SettingsDialog from "./settings-dialog";
|
||||||
|
import FileIcon from "~/components/ui/file-icon";
|
||||||
|
|
||||||
const Editor = () => {
|
const Editor = () => {
|
||||||
const { project, initialFiles } = useData<Data>();
|
const { project, initialFiles } = useData<Data>();
|
||||||
@ -125,9 +127,11 @@ const Editor = () => {
|
|||||||
tabs = tabs.concat(
|
tabs = tabs.concat(
|
||||||
curOpenFiles.map((fileId) => {
|
curOpenFiles.map((fileId) => {
|
||||||
const fileData = openedFiles?.find((i) => i.id === fileId);
|
const fileData = openedFiles?.find((i) => i.id === fileId);
|
||||||
|
const filename = fileData?.filename || "...";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: fileData?.filename || "...",
|
title: filename,
|
||||||
|
icon: <FileIcon file={{ isDirectory: false, filename }} />,
|
||||||
render: () => <FileViewer id={fileId} />,
|
render: () => <FileViewer id={fileId} />,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -180,6 +184,7 @@ const Editor = () => {
|
|||||||
current={Math.min(Math.max(curTabIdx, 0), tabs.length - 1)}
|
current={Math.min(Math.max(curTabIdx, 0), tabs.length - 1)}
|
||||||
onChange={setCurTabIdx}
|
onChange={setCurTabIdx}
|
||||||
onClose={onCloseFile}
|
onClose={onCloseFile}
|
||||||
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
@ -203,6 +208,8 @@ const Editor = () => {
|
|||||||
|
|
||||||
<StatusBar className="order-1 md:order-2" />
|
<StatusBar className="order-1 md:order-2" />
|
||||||
</PanelComponent>
|
</PanelComponent>
|
||||||
|
|
||||||
|
<SettingsDialog />
|
||||||
</EditorContext.Provider>
|
</EditorContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -24,6 +24,7 @@ import FileIcon from "~/components/ui/file-icon";
|
|||||||
import { useData } from "~/renderer/hooks";
|
import { useData } from "~/renderer/hooks";
|
||||||
import Spinner from "~/components/ui/spinner";
|
import Spinner from "~/components/ui/spinner";
|
||||||
import { Data } from "../+data";
|
import { Data } from "../+data";
|
||||||
|
import { settingsDialog } from "../stores/dialogs";
|
||||||
|
|
||||||
const FileListing = () => {
|
const FileListing = () => {
|
||||||
const { project, files: initialFiles } = useData<Data>();
|
const { project, files: initialFiles } = useData<Data>();
|
||||||
@ -59,7 +60,9 @@ const FileListing = () => {
|
|||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem>Upload File</DropdownMenuItem>
|
<DropdownMenuItem>Upload File</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>Project Settings</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => settingsDialog.setState(true)}>
|
||||||
|
Project Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>Download Project</DropdownMenuItem>
|
<DropdownMenuItem>Download Project</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@ -184,13 +187,18 @@ const FileItem = ({ file, createFileDlg }: FileItemProps) => {
|
|||||||
Copy Path
|
Copy Path
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => copy(getPreviewUrl(project, file))}
|
onClick={() =>
|
||||||
|
copy(getPreviewUrl(project, file, { raw: true }))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Copy URL
|
Copy URL
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(getPreviewUrl(project, file), "_blank")
|
window.open(
|
||||||
|
getPreviewUrl(project, file, { raw: true }),
|
||||||
|
"_blank"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Open in new tab
|
Open in new tab
|
||||||
|
175
pages/project/@slug/components/settings-dialog.tsx
Normal file
175
pages/project/@slug/components/settings-dialog.tsx
Normal file
@ -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 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 { useStore } from "zustand";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
@ -11,7 +17,7 @@ import { useProjectContext } from "../context/project";
|
|||||||
|
|
||||||
const StatusBar = ({ className }: React.ComponentProps<"div">) => {
|
const StatusBar = ({ className }: React.ComponentProps<"div">) => {
|
||||||
const { user, urlPathname } = usePageContext();
|
const { user, urlPathname } = usePageContext();
|
||||||
const { isCompact } = useProjectContext();
|
const { isCompact, project } = useProjectContext();
|
||||||
const sidebarExpanded = useStore(sidebarStore, (i) => i.expanded);
|
const sidebarExpanded = useStore(sidebarStore, (i) => i.expanded);
|
||||||
const previewExpanded = useStore(previewStore, (i) => i.open);
|
const previewExpanded = useStore(previewStore, (i) => i.open);
|
||||||
|
|
||||||
@ -22,24 +28,10 @@ const StatusBar = ({ className }: React.ComponentProps<"div">) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
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
|
<ActionButton
|
||||||
title="Return to Home"
|
title="Return to Home"
|
||||||
href="/"
|
href="/"
|
||||||
@ -54,6 +46,28 @@ const StatusBar = ({ className }: React.ComponentProps<"div">) => {
|
|||||||
<FiUser className="text-sm" />
|
<FiUser className="text-sm" />
|
||||||
{user?.name || "Log in"}
|
{user?.name || "Log in"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
30
pages/project/@slug/lib/consts.ts
Normal file
30
pages/project/@slug/lib/consts.ts
Normal file
@ -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",
|
||||||
|
},
|
||||||
|
];
|
35
pages/project/@slug/lib/schema.ts
Normal file
35
pages/project/@slug/lib/schema.ts
Normal file
@ -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>;
|
3
pages/project/@slug/stores/dialogs.ts
Normal file
3
pages/project/@slug/stores/dialogs.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { createStore } from "zustand";
|
||||||
|
|
||||||
|
export const settingsDialog = createStore<boolean>(() => false);
|
@ -9,15 +9,24 @@ type LinkProps = {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Link = ({ asChild, href, ...props }: LinkProps) => {
|
const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
|
||||||
const pageContext = usePageContext();
|
({ asChild, href, ...props }, ref) => {
|
||||||
const { urlPathname } = pageContext;
|
const pageContext = usePageContext();
|
||||||
const Comp = asChild ? Slot : "a";
|
const { urlPathname } = pageContext;
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
const isActive =
|
const isActive =
|
||||||
href === "/" ? urlPathname === href : urlPathname.startsWith(href!);
|
href === "/" ? urlPathname === href : urlPathname.startsWith(href!);
|
||||||
|
|
||||||
return <Comp data-active={isActive || undefined} href={href} {...props} />;
|
return (
|
||||||
};
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-active={isActive || undefined}
|
||||||
|
href={href}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default Link;
|
export default Link;
|
||||||
|
@ -19,6 +19,7 @@ const get = async (req: Request, res: Response) => {
|
|||||||
if (!projectData) {
|
if (!projectData) {
|
||||||
return res.status(404).send("Project not found!");
|
return res.status(404).send("Project not found!");
|
||||||
}
|
}
|
||||||
|
const settings = projectData.settings || {};
|
||||||
|
|
||||||
const fileData = await db.query.file.findFirst({
|
const fileData = await db.query.file.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
@ -34,16 +35,18 @@ const get = async (req: Request, res: Response) => {
|
|||||||
const ext = getFileExt(fileData.filename);
|
const ext = getFileExt(fileData.filename);
|
||||||
let content = fileData.content || "";
|
let content = fileData.content || "";
|
||||||
|
|
||||||
if (["html", "htm"].includes(ext)) {
|
if (!req.query.raw) {
|
||||||
content = await serveHtml(fileData);
|
if (["html", "htm"].includes(ext)) {
|
||||||
}
|
content = await serveHtml(fileData, settings);
|
||||||
|
}
|
||||||
|
|
||||||
if (["js", "ts", "jsx", "tsx"].includes(ext)) {
|
if (["js", "ts", "jsx", "tsx"].includes(ext)) {
|
||||||
content = await serveJs(fileData);
|
content = await serveJs(fileData, settings.js);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["css"].includes(ext)) {
|
if (["css"].includes(ext) && settings.css?.preprocessor === "postcss") {
|
||||||
content = await postcss(fileData);
|
content = await postcss(fileData, settings.css);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader("Content-Type", getMimeType(fileData.filename));
|
res.setHeader("Content-Type", getMimeType(fileData.filename));
|
||||||
|
@ -3,17 +3,26 @@ import tailwindcss from "tailwindcss";
|
|||||||
import cssnano from "cssnano";
|
import cssnano from "cssnano";
|
||||||
import { FileSchema } from "~/server/db/schema/file";
|
import { FileSchema } from "~/server/db/schema/file";
|
||||||
import { unpackProject } from "~/server/lib/unpack-project";
|
import { unpackProject } from "~/server/lib/unpack-project";
|
||||||
|
import { ProjectSettingsSchema } from "~/server/db/schema/project";
|
||||||
|
|
||||||
export const postcss = async (fileData: FileSchema) => {
|
export const postcss = async (
|
||||||
|
fileData: FileSchema,
|
||||||
|
cfg?: ProjectSettingsSchema["css"]
|
||||||
|
) => {
|
||||||
const content = fileData.content || "";
|
const content = fileData.content || "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectDir = await unpackProject({ ext: "ts,tsx,js,jsx,html" });
|
const projectDir = await unpackProject({ ext: "ts,tsx,js,jsx,html" });
|
||||||
|
const plugins: any[] = [];
|
||||||
|
|
||||||
|
if (cfg?.tailwindcss) {
|
||||||
|
plugins.push(
|
||||||
|
tailwindcss({ content: [projectDir + "/**/*.{ts,tsx,js,jsx,html}"] })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await postcssPlugin([
|
const result = await postcssPlugin([
|
||||||
tailwindcss({
|
...plugins,
|
||||||
content: [projectDir + "/**/*.{ts,tsx,js,jsx,html}"],
|
|
||||||
}),
|
|
||||||
cssnano({
|
cssnano({
|
||||||
preset: ["default", { discardComments: { removeAll: true } }],
|
preset: ["default", { discardComments: { removeAll: true } }],
|
||||||
}),
|
}),
|
||||||
|
@ -2,8 +2,12 @@ import db from "~/server/db";
|
|||||||
import { FileSchema, file } from "~/server/db/schema/file";
|
import { FileSchema, file } from "~/server/db/schema/file";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { IS_DEV } from "~/server/lib/consts";
|
import { IS_DEV } from "~/server/lib/consts";
|
||||||
|
import type { ProjectSettingsSchema } from "~/server/db/schema/project";
|
||||||
|
|
||||||
export const serveHtml = async (fileData: FileSchema) => {
|
export const serveHtml = async (
|
||||||
|
fileData: FileSchema,
|
||||||
|
settings: Partial<ProjectSettingsSchema>
|
||||||
|
) => {
|
||||||
const layout = await db.query.file.findFirst({
|
const layout = await db.query.file.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(file.projectId, fileData.projectId),
|
eq(file.projectId, fileData.projectId),
|
||||||
@ -24,18 +28,15 @@ export const serveHtml = async (fileData: FileSchema) => {
|
|||||||
const firstScriptTagIdx = content.indexOf("<script", bodyOpeningTagIdx);
|
const firstScriptTagIdx = content.indexOf("<script", bodyOpeningTagIdx);
|
||||||
const injectScripts = ['<script src="/js/hook-console.js"></script>'];
|
const injectScripts = ['<script src="/js/hook-console.js"></script>'];
|
||||||
|
|
||||||
|
// prevent direct access
|
||||||
if (!IS_DEV) {
|
if (!IS_DEV) {
|
||||||
// prevent direct access
|
|
||||||
injectScripts.push(
|
injectScripts.push(
|
||||||
`<script>if (window === window.parent) {window.location.href = '/';}</script>`
|
`<script>if (window === window.parent) {window.location.href = '/';}</script>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const importMaps = [
|
// js import maps
|
||||||
{ name: "react", url: "https://esm.sh/react@18.2.0" },
|
const importMaps = settings?.js?.packages || [];
|
||||||
{ name: "react-dom/client", url: "https://esm.sh/react-dom@18.2.0/client" },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (importMaps.length > 0) {
|
if (importMaps.length > 0) {
|
||||||
const imports = importMaps.reduce((a: any, b) => {
|
const imports = importMaps.reduce((a: any, b) => {
|
||||||
a[b.name] = b.url;
|
a[b.name] = b.url;
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
import { FileSchema } from "~/server/db/schema/file";
|
import { FileSchema } from "~/server/db/schema/file";
|
||||||
|
import { ProjectSettingsSchema } from "~/server/db/schema/project";
|
||||||
import { transformJs } from "~/server/lib/transform-js";
|
import { transformJs } from "~/server/lib/transform-js";
|
||||||
|
|
||||||
export const serveJs = async (file: FileSchema) => {
|
export const serveJs = async (
|
||||||
|
file: FileSchema,
|
||||||
|
cfg?: ProjectSettingsSchema["js"]
|
||||||
|
) => {
|
||||||
let content = file.content || "";
|
let content = file.content || "";
|
||||||
|
|
||||||
// transform js
|
// transpile to es5
|
||||||
content = await transformJs(content);
|
if (cfg?.transpiler === "swc") {
|
||||||
|
content = await transformJs(content);
|
||||||
|
}
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
import { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
||||||
|
|
||||||
export const createContext = async (ctx: CreateExpressContextOptions) => {
|
export const createContext = async (ctx: CreateExpressContextOptions) => {
|
||||||
return ctx;
|
return { ...ctx, user: ctx.req.user };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Context = Awaited<ReturnType<typeof createContext>>;
|
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||||
|
@ -1,22 +1,48 @@
|
|||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { user } from "./user";
|
import { user } from "./user";
|
||||||
import { file } from "./file";
|
import { file } from "./file";
|
||||||
|
|
||||||
export const project = sqliteTable("projects", {
|
const defaultSettings: ProjectSettingsSchema = {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
css: {
|
||||||
userId: integer("user_id")
|
preprocessor: null,
|
||||||
.notNull()
|
tailwindcss: false,
|
||||||
.references(() => user.id),
|
},
|
||||||
slug: text("slug").notNull().unique(),
|
js: {
|
||||||
title: text("title").notNull(),
|
transpiler: null,
|
||||||
createdAt: text("created_at")
|
packages: [],
|
||||||
.notNull()
|
},
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
};
|
||||||
deletedAt: text("deleted_at"),
|
|
||||||
});
|
export const project = sqliteTable(
|
||||||
|
"projects",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id),
|
||||||
|
slug: text("slug").notNull().unique(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
|
||||||
|
visibility: text("visibility", {
|
||||||
|
enum: ["public", "private", "unlisted"],
|
||||||
|
}).default("private"),
|
||||||
|
|
||||||
|
settings: text("settings", { mode: "json" })
|
||||||
|
.$type<Partial<ProjectSettingsSchema>>()
|
||||||
|
.default(defaultSettings),
|
||||||
|
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
visibilityEnum: index("project_visibility_idx").on(table.visibility),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const projectRelations = relations(project, ({ one, many }) => ({
|
export const projectRelations = relations(project, ({ one, many }) => ({
|
||||||
files: many(file),
|
files: many(file),
|
||||||
@ -26,7 +52,27 @@ export const projectRelations = relations(project, ({ one, many }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const insertProjectSchema = createInsertSchema(project);
|
export const projectSettingsSchema = z.object({
|
||||||
export const selectProjectSchema = createSelectSchema(project);
|
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(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProjectSettingsSchema = z.infer<typeof projectSettingsSchema>;
|
||||||
|
|
||||||
|
export const insertProjectSchema = createInsertSchema(project, {
|
||||||
|
settings: projectSettingsSchema,
|
||||||
|
});
|
||||||
|
export const selectProjectSchema = createSelectSchema(project, {
|
||||||
|
settings: projectSettingsSchema.partial(),
|
||||||
|
});
|
||||||
|
|
||||||
export type ProjectSchema = z.infer<typeof selectProjectSchema>;
|
export type ProjectSchema = z.infer<typeof selectProjectSchema>;
|
||||||
|
@ -17,7 +17,12 @@ const main = async () => {
|
|||||||
|
|
||||||
const [vanillaProject] = await db
|
const [vanillaProject] = await db
|
||||||
.insert(project)
|
.insert(project)
|
||||||
.values({ userId: adminUser.id, slug: uid(), title: "Vanilla Project" })
|
.values({
|
||||||
|
userId: adminUser.id,
|
||||||
|
slug: uid(),
|
||||||
|
title: "Vanilla Project",
|
||||||
|
visibility: "public",
|
||||||
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// vanilla html css js template
|
// vanilla html css js template
|
||||||
@ -69,7 +74,28 @@ const main = async () => {
|
|||||||
|
|
||||||
const [reactProject] = await db
|
const [reactProject] = await db
|
||||||
.insert(project)
|
.insert(project)
|
||||||
.values({ userId: adminUser.id, slug: uid(), title: "React Project" })
|
.values({
|
||||||
|
userId: adminUser.id,
|
||||||
|
slug: uid(),
|
||||||
|
title: "React Project",
|
||||||
|
visibility: "public",
|
||||||
|
settings: {
|
||||||
|
css: {
|
||||||
|
preprocessor: "postcss",
|
||||||
|
tailwindcss: true,
|
||||||
|
},
|
||||||
|
js: {
|
||||||
|
transpiler: "swc",
|
||||||
|
packages: [
|
||||||
|
{ name: "react", url: "https://esm.sh/react@18.2.0" },
|
||||||
|
{
|
||||||
|
name: "react-dom/client",
|
||||||
|
url: "https://esm.sh/react-dom@18.2.0/client",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// react template
|
// react template
|
||||||
|
@ -23,7 +23,6 @@ export const unpackProject = async (
|
|||||||
|
|
||||||
const projectDir = getProjectDir();
|
const projectDir = getProjectDir();
|
||||||
if (!fileExists(projectDir)) {
|
if (!fileExists(projectDir)) {
|
||||||
console.log("not exist", projectDir);
|
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,21 +14,34 @@ import { faker } from "@faker-js/faker";
|
|||||||
import { ucwords } from "~/lib/utils";
|
import { ucwords } from "~/lib/utils";
|
||||||
import { hashPassword } from "../lib/crypto";
|
import { hashPassword } from "../lib/crypto";
|
||||||
import { createToken } from "../lib/jwt";
|
import { createToken } from "../lib/jwt";
|
||||||
|
import { file } from "../db/schema/file";
|
||||||
|
|
||||||
const projectRouter = router({
|
const projectRouter = router({
|
||||||
getAll: procedure.query(async () => {
|
getAll: procedure
|
||||||
const where = and(isNull(project.deletedAt));
|
.input(z.object({ owned: z.boolean() }).partial().optional())
|
||||||
|
.query(async ({ ctx, input: opt }) => {
|
||||||
|
if (opt?.owned && !ctx.user) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
|
||||||
const projects = await db.query.project.findMany({
|
const where = [
|
||||||
where,
|
!opt?.owned
|
||||||
with: {
|
? eq(project.visibility, "public")
|
||||||
user: { columns: { password: false } },
|
: eq(project.userId, ctx.user!.id),
|
||||||
},
|
isNull(project.deletedAt),
|
||||||
orderBy: [desc(project.id)],
|
];
|
||||||
});
|
|
||||||
|
|
||||||
return projects;
|
const projects = await db.query.project.findMany({
|
||||||
}),
|
where: and(...(where.filter((i) => i != null) as any)),
|
||||||
|
columns: { settings: false },
|
||||||
|
with: {
|
||||||
|
user: { columns: { password: false } },
|
||||||
|
},
|
||||||
|
orderBy: [desc(project.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return projects;
|
||||||
|
}),
|
||||||
|
|
||||||
getById: procedure
|
getById: procedure
|
||||||
.input(z.number().or(z.string()))
|
.input(z.number().or(z.string()))
|
||||||
@ -70,10 +83,10 @@ const projectRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const title =
|
const title =
|
||||||
input.title.length > 0 ? input.title : ucwords(faker.lorem.words(2));
|
input.title.length > 0 ? input.title : ucwords(faker.lorem.words(2));
|
||||||
let userId = 0;
|
let userId = ctx.user?.id;
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
if (input.user) {
|
if (input.user && !userId) {
|
||||||
const [usr] = await tx
|
const [usr] = await tx
|
||||||
.insert(user)
|
.insert(user)
|
||||||
.values({
|
.values({
|
||||||
@ -99,15 +112,52 @@ const projectRouter = router({
|
|||||||
slug: uid(),
|
slug: uid(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [result] = await tx.insert(project).values(data).returning();
|
const [projectData] = await tx.insert(project).values(data).returning();
|
||||||
|
const projectId = projectData.id;
|
||||||
|
|
||||||
return result;
|
if (input.forkFromId) {
|
||||||
|
const forkFiles = await tx.query.file.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(file.projectId, input.forkFromId),
|
||||||
|
isNull(file.deletedAt)
|
||||||
|
),
|
||||||
|
columns: {
|
||||||
|
id: false,
|
||||||
|
projectId: false,
|
||||||
|
createdAt: false,
|
||||||
|
deletedAt: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(file)
|
||||||
|
.values(forkFiles.map((file) => ({ ...file, projectId })));
|
||||||
|
} else {
|
||||||
|
await tx.insert(file).values([
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
path: "index.html",
|
||||||
|
filename: "index.html",
|
||||||
|
content: "<p>Open index.html to edit this file.</p>",
|
||||||
|
isPinned: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectData;
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: procedure
|
update: procedure
|
||||||
.input(selectProjectSchema.partial().required({ id: true }))
|
.input(
|
||||||
|
selectProjectSchema
|
||||||
|
.partial()
|
||||||
|
.omit({ slug: true, userId: true })
|
||||||
|
.required({ id: true })
|
||||||
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
const data = { ...input };
|
||||||
|
|
||||||
const projectData = await db.query.project.findFirst({
|
const projectData = await db.query.project.findFirst({
|
||||||
where: and(eq(project.id, input.id), isNull(project.deletedAt)),
|
where: and(eq(project.id, input.id), isNull(project.deletedAt)),
|
||||||
});
|
});
|
||||||
@ -115,9 +165,16 @@ const projectRouter = router({
|
|||||||
throw new TRPCError({ code: "NOT_FOUND" });
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.settings) {
|
||||||
|
data.settings = Object.assign(
|
||||||
|
projectData.settings || {},
|
||||||
|
data.settings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.update(project)
|
.update(project)
|
||||||
.set(input)
|
.set(data)
|
||||||
.where(and(eq(project.id, input.id), isNull(project.deletedAt)))
|
.where(and(eq(project.id, input.id), isNull(project.deletedAt)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user