mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-07 20:44:05 +09:00
Merge pull request #136 from webstudio-is/upload-assets-pt1
This commit is contained in:
20 files changed
+248
-20
No files matched your search
@@ -40,3 +40,4 @@ yarn-error.log*
|
||||
/data
|
||||
*.db
|
||||
*.db-journal
|
||||
uploads
|
||||
@@ -2,4 +2,8 @@ DATABASE_URL=postgresql://user:pass@localhost/webstudio
|
||||
AUTH_SECRET="# Linux: `openssl rand -hex 32` or go to https://generate-secret.now.sh/32"
|
||||
GH_CLIENT_SECRET=
|
||||
GH_CLIENT_ID=
|
||||
DEV_LOGIN=true
|
||||
DEV_LOGIN=true
|
||||
|
||||
|
||||
# relative to the public directory
|
||||
FILE_UPLOAD_PATH="uploads"
|
||||
@@ -8,6 +8,8 @@ const config = {
|
||||
googleCallbackPath: "/google/callback",
|
||||
githubCallbackPath: "/github/callback",
|
||||
authPath: "/auth",
|
||||
|
||||
defaultUploadPath: "uploads",
|
||||
};
|
||||
export default config;
|
||||
export type Config = typeof config;
|
||||
@@ -3,6 +3,7 @@ import { DndProvider } from "react-dnd";
|
||||
import { TouchBackend } from "react-dnd-touch-backend";
|
||||
import {
|
||||
type Project,
|
||||
type Asset,
|
||||
useSubscribe,
|
||||
usePublish,
|
||||
} from "@webstudio-is/react-sdk";
|
||||
@@ -176,9 +177,10 @@ const dndOptions = { enableMouseEvents: true };
|
||||
type DesignerProps = {
|
||||
config: Config;
|
||||
project: Project;
|
||||
assets: Array<Asset>;
|
||||
};
|
||||
|
||||
export const Designer = ({ config, project }: DesignerProps) => {
|
||||
export const Designer = ({ config, project, assets }: DesignerProps) => {
|
||||
useSubscribeSyncStatus();
|
||||
useSubscribeRootInstance();
|
||||
useSubscribeSelectedInstanceData();
|
||||
@@ -225,7 +227,11 @@ export const Designer = ({ config, project }: DesignerProps) => {
|
||||
</Workspace>
|
||||
</Main>
|
||||
<SidePanel gridArea="sidebar" isPreviewMode={isPreviewMode}>
|
||||
<SidebarLeft onDragChange={setIsDragging} publish={publish} />
|
||||
<SidebarLeft
|
||||
assets={assets}
|
||||
onDragChange={setIsDragging}
|
||||
publish={publish}
|
||||
/>
|
||||
</SidePanel>
|
||||
<SidePanel
|
||||
gridArea="inspector"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ImageIcon } from "~/shared/icons";
|
||||
import { Button, Flex, Grid, Heading } from "~/shared/design-system";
|
||||
import { useRef } from "react";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { type Asset } from "@webstudio-is/react-sdk";
|
||||
import { Image } from "~/shared/design-system/components/image";
|
||||
|
||||
export const TabContent = ({ assets }: { assets: Array<Asset> }) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const onChange = () => {
|
||||
if (submitButtonRef.current) {
|
||||
submitButtonRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap="3" direction="column" css={{ padding: "$1" }}>
|
||||
<Flex justify="between">
|
||||
<Heading>Assets</Heading>
|
||||
<Form method="post" encType="multipart/form-data">
|
||||
<input
|
||||
accept="image/*"
|
||||
type="file"
|
||||
name="image"
|
||||
multiple
|
||||
ref={inputRef}
|
||||
onChange={onChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button
|
||||
ref={submitButtonRef}
|
||||
type="submit"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<Button onClick={() => inputRef?.current?.click()}>
|
||||
Upload Image
|
||||
</Button>
|
||||
</Form>
|
||||
</Flex>
|
||||
<Grid columns={2} gap={2}>
|
||||
{assets.map((asset) => (
|
||||
<Image
|
||||
key={asset.id}
|
||||
src={asset.path}
|
||||
alt={asset.alt || asset.name}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export const icon = <ImageIcon />;
|
||||
@@ -1,2 +1,5 @@
|
||||
export * as components from "./components";
|
||||
export * as tree from "./tree";
|
||||
import * as components from "./components";
|
||||
import * as tree from "./tree";
|
||||
import * as imageUpload from "./image-upload";
|
||||
|
||||
export const panels = { components, tree, imageUpload } as const;
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useSubscribe, type Publish } from "@webstudio-is/react-sdk";
|
||||
import {
|
||||
useSubscribe,
|
||||
type Publish,
|
||||
type Asset,
|
||||
} from "@webstudio-is/react-sdk";
|
||||
import {
|
||||
Box,
|
||||
SidebarTabs,
|
||||
@@ -8,8 +12,9 @@ import {
|
||||
SidebarTabsTrigger,
|
||||
} from "~/shared/design-system";
|
||||
import { useSelectedInstanceData } from "../../shared/nano-states";
|
||||
import * as panels from "./panels";
|
||||
import { panels } from "./panels";
|
||||
import type { TabName } from "./types";
|
||||
import { isFeatureEnabled } from "~/shared/feature-flags";
|
||||
|
||||
const sidebarTabsContentStyle = {
|
||||
position: "absolute",
|
||||
@@ -26,9 +31,14 @@ const none = { TabContent: () => null };
|
||||
type SidebarLeftProps = {
|
||||
onDragChange: (isDragging: boolean) => void;
|
||||
publish: Publish;
|
||||
assets: Array<Asset>;
|
||||
};
|
||||
|
||||
export const SidebarLeft = ({ onDragChange, publish }: SidebarLeftProps) => {
|
||||
export const SidebarLeft = ({
|
||||
onDragChange,
|
||||
publish,
|
||||
assets,
|
||||
}: SidebarLeftProps) => {
|
||||
const [selectedInstanceData] = useSelectedInstanceData();
|
||||
const [activeTab, setActiveTab] = useState<TabName>("none");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -55,11 +65,17 @@ export const SidebarLeft = ({ onDragChange, publish }: SidebarLeftProps) => {
|
||||
[onDragChange]
|
||||
);
|
||||
|
||||
const enabledPanels = (
|
||||
isFeatureEnabled("assets")
|
||||
? Object.keys(panels)
|
||||
: Object.keys(panels).filter((panel) => panel !== "imageUpload")
|
||||
) as Array<TabName>;
|
||||
|
||||
return (
|
||||
<Box css={{ position: "relative", zIndex: 1 }}>
|
||||
<SidebarTabs activationMode="manual" value={activeTab}>
|
||||
<SidebarTabsList>
|
||||
{(Object.keys(panels) as Array<TabName>).map((tabName: TabName) => (
|
||||
{enabledPanels.map((tabName: TabName) => (
|
||||
<SidebarTabsTrigger
|
||||
aria-label={tabName}
|
||||
key={tabName}
|
||||
@@ -83,6 +99,7 @@ export const SidebarLeft = ({ onDragChange, publish }: SidebarLeftProps) => {
|
||||
}}
|
||||
>
|
||||
<TabContent
|
||||
assets={assets}
|
||||
selectedInstanceData={selectedInstanceData}
|
||||
publish={publish}
|
||||
onSetActiveTab={setActiveTab}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import * as tabs from "./panels";
|
||||
import { panels } from "./panels";
|
||||
import { z } from "zod";
|
||||
|
||||
export type TabName = keyof typeof tabs | "none";
|
||||
export type TabName = keyof typeof panels | "none";
|
||||
|
||||
const SingleImageInUpload = z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
export const ImagesUpload = z.array(SingleImageInUpload);
|
||||
|
||||
export type ImagesUpload = z.infer<typeof ImagesUpload>;
|
||||
export type SingleImageInUpload = z.infer<typeof SingleImageInUpload>;
|
||||
@@ -4,7 +4,7 @@ const env = {
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
VERCEL_ENV: process.env.VERCEL_ENV,
|
||||
DEBUG: process.env.DEBUG,
|
||||
FEATURE: process.env.FEATURE,
|
||||
FEATURES: process.env.FEATURES,
|
||||
} as const;
|
||||
|
||||
export default env;
|
||||
|
||||
@@ -1,30 +1,81 @@
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { LoaderFunction } from "@remix-run/node";
|
||||
import type { Project } from "@webstudio-is/react-sdk";
|
||||
import path from "path";
|
||||
import {
|
||||
ActionFunction,
|
||||
LoaderFunction,
|
||||
unstable_createFileUploadHandler,
|
||||
unstable_parseMultipartFormData,
|
||||
} from "@remix-run/node";
|
||||
import type { Project, Asset } from "@webstudio-is/react-sdk";
|
||||
import { Designer, links } from "~/designer";
|
||||
import * as db from "~/shared/db";
|
||||
import config from "~/config";
|
||||
import env from "~/env.server";
|
||||
import { ImagesUpload } from "~/designer/features/sidebar-left/types";
|
||||
|
||||
export { links };
|
||||
|
||||
export const loader: LoaderFunction = async ({ params }) => {
|
||||
if (params.id === undefined) throw new Error("Project id undefined");
|
||||
const project = await db.project.loadById(params.id);
|
||||
const assets = await db.assets.loadByProject(params.id);
|
||||
if (project === null) {
|
||||
return { errors: `Project "${params.id}" not found` };
|
||||
}
|
||||
return { config, project, env };
|
||||
return { config, assets, project, env };
|
||||
};
|
||||
|
||||
type Data = {
|
||||
config: typeof config;
|
||||
project: Project;
|
||||
assets: Asset[];
|
||||
};
|
||||
|
||||
type Error = {
|
||||
errors: "string";
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
if (params.id === undefined) throw new Error("Project id undefined");
|
||||
|
||||
const uploads = path.join(__dirname, "../public");
|
||||
const folderInPublic =
|
||||
process.env.FILE_UPLOAD_PATH || config.defaultUploadPath;
|
||||
const directory = path.join(uploads, folderInPublic);
|
||||
|
||||
try {
|
||||
const formData = await unstable_parseMultipartFormData(
|
||||
request,
|
||||
unstable_createFileUploadHandler({
|
||||
maxPartSize: 10_000_000,
|
||||
directory,
|
||||
file: ({ filename }) => filename,
|
||||
})
|
||||
);
|
||||
|
||||
const imagesInfo = ImagesUpload.parse(formData.getAll("image"));
|
||||
imagesInfo.forEach((image) => {
|
||||
const data = {
|
||||
type: image.type,
|
||||
name: image.name,
|
||||
path: `${path.join("/", folderInPublic, image.name)}`,
|
||||
};
|
||||
const projectId = params.id as string;
|
||||
db.assets.create(projectId, data);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
errors: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const DesignerRoute = () => {
|
||||
const data = useLoaderData<Data | Error>();
|
||||
if ("errors" in data) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type Project } from "@webstudio-is/react-sdk";
|
||||
import { prisma } from "./prisma.server";
|
||||
|
||||
export const loadByProject = async (projectId?: Project["id"]) => {
|
||||
if (typeof projectId !== "string") {
|
||||
throw new Error("Tree ID required");
|
||||
}
|
||||
|
||||
const assets = await prisma.asset.findMany({
|
||||
where: { projectId },
|
||||
});
|
||||
|
||||
return assets;
|
||||
};
|
||||
|
||||
export const create = async (
|
||||
projectId: Project["id"],
|
||||
values: { type: string; name: string; path: string }
|
||||
) => {
|
||||
const newAsset = await prisma.asset.create({
|
||||
data: {
|
||||
...values,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return newAsset;
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export * as misc from "./misc.server";
|
||||
export * as user from "./user.server";
|
||||
export * from "./canvas.server";
|
||||
export * as breakpoints from "./breakpoints.server";
|
||||
export * as assets from "./assets.server";
|
||||
@@ -9,7 +9,7 @@ const parse = (flags?: string | null): Array<Name> =>
|
||||
|
||||
const readLocal = (): Array<Name> => {
|
||||
try {
|
||||
const flags = localStorage.getItem("feature");
|
||||
const flags = localStorage.getItem("features");
|
||||
return parse(flags);
|
||||
} catch (_error) {
|
||||
// Not having feature in localStorage or not having localStorage implemented, both should not throw.
|
||||
@@ -21,12 +21,12 @@ const readLocal = (): Array<Name> => {
|
||||
* Returns true/false if the feature is turned on.
|
||||
* A feature can be turned on:
|
||||
* - by default directly in ./flags
|
||||
* - by providing an environment variable server-side (locally or on the server): FEATURE="something1, something2" yarn dev
|
||||
* - by setting it in the browser console: localStorage.feature = 'something1, something2', browser defined flag will override server-side flag
|
||||
* - by providing an environment variable server-side (locally or on the server): FEATURES="something1, something2" yarn dev
|
||||
* - by setting it in the browser console: localStorage.features = 'something1, something2', browser defined flag will override server-side flag
|
||||
*/
|
||||
export const isFeatureEnabled = (name: Name): boolean => {
|
||||
const defaultValue = flags[name];
|
||||
const envValue = parse(env.FEATURE).includes(name);
|
||||
const envValue = parse(env.FEATURES).includes(name);
|
||||
const localValue = readLocal().includes(name);
|
||||
// Any source can enable feature, first `true` value will result in enabling a feature.
|
||||
// This also means you can't disable a feature if its already enabled in default value.
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const assets = false;
|
||||
export const example = false;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Asset" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"size" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `size` on the `Asset` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Asset" DROP COLUMN "size";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Asset" ADD COLUMN "alt" TEXT;
|
||||
@@ -13,7 +13,17 @@ datasource db {
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
users User[]
|
||||
}
|
||||
|
||||
model Asset {
|
||||
id String @id @default(uuid())
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
projectId String
|
||||
type String
|
||||
name String
|
||||
path String
|
||||
alt String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -37,6 +47,7 @@ model Project {
|
||||
prodTreeIdHistory String @default("[]")
|
||||
User User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
Asset Asset[]
|
||||
}
|
||||
|
||||
model Tree {
|
||||
|
||||
@@ -13,7 +13,17 @@ datasource db {
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
users User[]
|
||||
}
|
||||
|
||||
model Asset {
|
||||
id String @id @default(uuid())
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
projectId String
|
||||
type String
|
||||
name String
|
||||
path String
|
||||
alt String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -37,6 +47,7 @@ model Project {
|
||||
prodTreeIdHistory String @default("[]")
|
||||
User User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
Asset Asset[]
|
||||
}
|
||||
|
||||
model Tree {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Project,
|
||||
User,
|
||||
Breakpoints,
|
||||
Asset,
|
||||
} from "@prisma/client";
|
||||
import type { UserProp } from "../user-props";
|
||||
import type { Instance } from "./instance";
|
||||
@@ -16,4 +17,4 @@ export type InstanceProps = Omit<DbInstanceProps, "props"> & {
|
||||
props: Array<UserProp>;
|
||||
};
|
||||
|
||||
export type { DbInstanceProps, Project, User, Breakpoints };
|
||||
export type { DbInstanceProps, Project, User, Breakpoints, Asset };
|
||||
Reference in new issue
Block a user