diff --git a/.gitignore b/.gitignore index 6391b4dd9c..b74e9a335a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ yarn-error.log* /data *.db *.db-journal +uploads diff --git a/apps/designer/.env.example b/apps/designer/.env.example index 346a8f74da..0c377438b8 100644 --- a/apps/designer/.env.example +++ b/apps/designer/.env.example @@ -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 \ No newline at end of file +DEV_LOGIN=true + + +# relative to the public directory +FILE_UPLOAD_PATH="uploads" \ No newline at end of file diff --git a/apps/designer/app/config.ts b/apps/designer/app/config.ts index 7bc8840d93..4ceb4e50d7 100644 --- a/apps/designer/app/config.ts +++ b/apps/designer/app/config.ts @@ -8,6 +8,8 @@ const config = { googleCallbackPath: "/google/callback", githubCallbackPath: "/github/callback", authPath: "/auth", + + defaultUploadPath: "uploads", }; export default config; export type Config = typeof config; diff --git a/apps/designer/app/designer/designer.tsx b/apps/designer/app/designer/designer.tsx index d7f6d4cd54..253dcfac76 100644 --- a/apps/designer/app/designer/designer.tsx +++ b/apps/designer/app/designer/designer.tsx @@ -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; }; -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) => { - + }) => { + const inputRef = useRef(null); + const submitButtonRef = useRef(null); + + const onChange = () => { + if (submitButtonRef.current) { + submitButtonRef.current.click(); + } + }; + + return ( + + + Assets +
+ + +
+
+ + {assets.map((asset) => ( + {asset.alt + ))} + +
+ ); +}; + +export const icon = ; diff --git a/apps/designer/app/designer/features/sidebar-left/panels/index.ts b/apps/designer/app/designer/features/sidebar-left/panels/index.ts index 589445e67f..8b7896fe93 100644 --- a/apps/designer/app/designer/features/sidebar-left/panels/index.ts +++ b/apps/designer/app/designer/features/sidebar-left/panels/index.ts @@ -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; diff --git a/apps/designer/app/designer/features/sidebar-left/sidebar-left.tsx b/apps/designer/app/designer/features/sidebar-left/sidebar-left.tsx index ab9de19a47..34341be7c7 100644 --- a/apps/designer/app/designer/features/sidebar-left/sidebar-left.tsx +++ b/apps/designer/app/designer/features/sidebar-left/sidebar-left.tsx @@ -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; }; -export const SidebarLeft = ({ onDragChange, publish }: SidebarLeftProps) => { +export const SidebarLeft = ({ + onDragChange, + publish, + assets, +}: SidebarLeftProps) => { const [selectedInstanceData] = useSelectedInstanceData(); const [activeTab, setActiveTab] = useState("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; + return ( - {(Object.keys(panels) as Array).map((tabName: TabName) => ( + {enabledPanels.map((tabName: TabName) => ( { }} > ; +export type SingleImageInUpload = z.infer; diff --git a/apps/designer/app/env.server.ts b/apps/designer/app/env.server.ts index 7040eecefb..47aacbc299 100644 --- a/apps/designer/app/env.server.ts +++ b/apps/designer/app/env.server.ts @@ -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; diff --git a/apps/designer/app/routes/designer/$id.tsx b/apps/designer/app/routes/designer/$id.tsx index f90a448e80..9e50b90c23 100644 --- a/apps/designer/app/routes/designer/$id.tsx +++ b/apps/designer/app/routes/designer/$id.tsx @@ -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(); if ("errors" in data) { diff --git a/apps/designer/app/shared/db/assets.server.ts b/apps/designer/app/shared/db/assets.server.ts new file mode 100644 index 0000000000..92ddc1739d --- /dev/null +++ b/apps/designer/app/shared/db/assets.server.ts @@ -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; +}; diff --git a/apps/designer/app/shared/db/index.ts b/apps/designer/app/shared/db/index.ts index 8e5524eaa7..34e3de24f5 100644 --- a/apps/designer/app/shared/db/index.ts +++ b/apps/designer/app/shared/db/index.ts @@ -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"; diff --git a/apps/designer/app/shared/feature-flags/feature.ts b/apps/designer/app/shared/feature-flags/feature.ts index 185e07aabb..00b1c0aff7 100644 --- a/apps/designer/app/shared/feature-flags/feature.ts +++ b/apps/designer/app/shared/feature-flags/feature.ts @@ -9,7 +9,7 @@ const parse = (flags?: string | null): Array => const readLocal = (): Array => { 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 => { * 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. diff --git a/apps/designer/app/shared/feature-flags/flags.ts b/apps/designer/app/shared/feature-flags/flags.ts index 893eee2955..56e8f1d294 100644 --- a/apps/designer/app/shared/feature-flags/flags.ts +++ b/apps/designer/app/shared/feature-flags/flags.ts @@ -1 +1,2 @@ +export const assets = false; export const example = false; diff --git a/apps/designer/prisma/migrations/20220714112221_add_assets/migration.sql b/apps/designer/prisma/migrations/20220714112221_add_assets/migration.sql new file mode 100644 index 0000000000..24b3a97e3c --- /dev/null +++ b/apps/designer/prisma/migrations/20220714112221_add_assets/migration.sql @@ -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; diff --git a/apps/designer/prisma/migrations/20220714114102_remove_size/migration.sql b/apps/designer/prisma/migrations/20220714114102_remove_size/migration.sql new file mode 100644 index 0000000000..edc101b235 --- /dev/null +++ b/apps/designer/prisma/migrations/20220714114102_remove_size/migration.sql @@ -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"; diff --git a/apps/designer/prisma/migrations/20220715192633_add_alt/migration.sql b/apps/designer/prisma/migrations/20220715192633_add_alt/migration.sql new file mode 100644 index 0000000000..86541afa67 --- /dev/null +++ b/apps/designer/prisma/migrations/20220715192633_add_alt/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Asset" ADD COLUMN "alt" TEXT; diff --git a/apps/designer/prisma/schema.prisma b/apps/designer/prisma/schema.prisma index c7a5edb888..26015a5835 100644 --- a/apps/designer/prisma/schema.prisma +++ b/apps/designer/prisma/schema.prisma @@ -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 { diff --git a/packages/webstudio-react-sdk/prisma/schema.prisma b/packages/webstudio-react-sdk/prisma/schema.prisma index c7a5edb888..26015a5835 100644 --- a/packages/webstudio-react-sdk/prisma/schema.prisma +++ b/packages/webstudio-react-sdk/prisma/schema.prisma @@ -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 { diff --git a/packages/webstudio-react-sdk/src/db/types.ts b/packages/webstudio-react-sdk/src/db/types.ts index bb60157308..ec8ea7db91 100644 --- a/packages/webstudio-react-sdk/src/db/types.ts +++ b/packages/webstudio-react-sdk/src/db/types.ts @@ -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 & { props: Array; }; -export type { DbInstanceProps, Project, User, Breakpoints }; +export type { DbInstanceProps, Project, User, Breakpoints, Asset };