diff --git a/CMakeLists.txt b/CMakeLists.txt index a31524337..30b5a15cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -245,6 +245,7 @@ ExternalProject_Add( set(JS_DIRS services/system/Accounts/ui:Accounts_js services/user/Homepage/ui:Homepage_js + services/user/Workshop/ui:Workshop_js services/user/Supervisor/ui:Supervisor_js services/user/Explorer/ui:Explorer_js services/user/Tokens/ui:Tokens_js @@ -730,6 +731,17 @@ psibase_package( DEPENDS wasm ) +psibase_package( + OUTPUT ${SERVICE_DIR}/Workshop.psi + NAME Workshop + VERSION ${PSIBASE_VERSION} + DESCRIPTION "A dashboard for developers to create and manage apps" + PACKAGE_DEPENDS "HttpServer(^${PSIBASE_VERSION})" "Sites(^${PSIBASE_VERSION})" "Accounts(^${PSIBASE_VERSION})" "CommonApi(^${PSIBASE_VERSION})" "Registry(^${PSIBASE_VERSION})" + SERVICE workshop + DATA GLOB ${CMAKE_CURRENT_SOURCE_DIR}/services/user/Workshop/ui/dist/* / + DEPENDS ${Workshop_js_DEP} +) + psibase_package( OUTPUT ${SERVICE_DIR}/Fractal.psi NAME Fractal @@ -773,7 +785,7 @@ psibase_package( DESCRIPTION "All development services" PACKAGE_DEPENDS Accounts Registry AuthAny AuthSig AuthDelegate Branding Brotli Chainmail ClientData CommonApi CpuLimit Docs Events Explorer Fractal Invite Nft Packages Producers HttpServer - Sites SetCode StagedTx Supervisor Symbol Tokens Transact Homepage + Sites SetCode StagedTx Supervisor Symbol Tokens Transact Homepage Workshop ) psibase_package( @@ -783,7 +795,7 @@ psibase_package( DESCRIPTION "All production services" PACKAGE_DEPENDS Accounts Registry AuthAny AuthSig AuthDelegate Branding Brotli Chainmail ClientData CommonApi CpuLimit Docs Events Explorer Fractal Invite Nft Packages Producers HttpServer - Sites SetCode StagedTx Supervisor Symbol Tokens Transact Homepage + Sites SetCode StagedTx Supervisor Symbol Tokens Transact Homepage Workshop ) @@ -820,7 +832,7 @@ endfunction() write_package_index(package-index ${SERVICE_DIR} Accounts AuthAny AuthDelegate AuthSig Branding Brotli Chainmail ClientData CommonApi CpuLimit DevDefault ProdDefault Docs Events Explorer Fractal Invite Nft Nop Minimal Packages Producers TestDefault HttpServer - Registry Sites SetCode StagedTx Supervisor Symbol TokenUsers Tokens Transact Homepage) + Registry Sites SetCode StagedTx Supervisor Symbol TokenUsers Tokens Transact Homepage Workshop) install( FILES ${SERVICE_DIR}/index.json @@ -850,6 +862,7 @@ install( ${SERVICE_DIR}/TestDefault.psi ${SERVICE_DIR}/HttpServer.psi ${SERVICE_DIR}/Registry.psi + ${SERVICE_DIR}/Workshop.psi ${SERVICE_DIR}/Sites.psi ${SERVICE_DIR}/SetCode.psi ${SERVICE_DIR}/StagedTx.psi diff --git a/services/user/Homepage/ui/src/lib/hooks/useCurrentAccounts.ts b/services/user/Homepage/ui/src/lib/hooks/useCurrentAccounts.ts index 924bccf1e..25f39aefb 100644 --- a/services/user/Homepage/ui/src/lib/hooks/useCurrentAccounts.ts +++ b/services/user/Homepage/ui/src/lib/hooks/useCurrentAccounts.ts @@ -22,4 +22,4 @@ export const useCurrentAccounts = (enabled: boolean) => return res; }, - }); + }); \ No newline at end of file diff --git a/services/user/Sites/src/Sites.cpp b/services/user/Sites/src/Sites.cpp index 3c1685f25..274fda08d 100644 --- a/services/user/Sites/src/Sites.cpp +++ b/services/user/Sites/src/Sites.cpp @@ -100,7 +100,7 @@ namespace SystemService "default-src 'self';" // "font-src 'self' https:;" // "script-src 'self' 'unsafe-eval' 'unsafe-inline' blob: https:;" // - "img-src *;" // + "img-src * data:;" // "style-src 'self' 'unsafe-inline';" // "frame-src *;" // "connect-src * blob:;" // diff --git a/services/user/Workshop/ui/components.json b/services/user/Workshop/ui/components.json new file mode 100644 index 000000000..3b9b7cc29 --- /dev/null +++ b/services/user/Workshop/ui/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/services/user/Workshop/ui/eslint.config.js b/services/user/Workshop/ui/eslint.config.js new file mode 100644 index 000000000..0bbf074e3 --- /dev/null +++ b/services/user/Workshop/ui/eslint.config.js @@ -0,0 +1,28 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + } +); diff --git a/services/user/Workshop/ui/index.html b/services/user/Workshop/ui/index.html new file mode 100644 index 000000000..af1852e25 --- /dev/null +++ b/services/user/Workshop/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Workshop + + +
+ + + diff --git a/services/user/Workshop/ui/package.json b/services/user/Workshop/ui/package.json new file mode 100644 index 000000000..40b6e776f --- /dev/null +++ b/services/user/Workshop/ui/package.json @@ -0,0 +1,57 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.5", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.1.2", + "@rollup/plugin-alias": "^5.1.1", + "@tanstack/react-query": "^5.52.0", + "axios": "^1.7.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.13", + "debounce": "^2.1.0", + "framer-motion": "^11.3.29", + "lucide-react": "^0.429.0", + "next-themes": "^0.4.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", + "react-router-dom": "^6.26.1", + "sonner": "^1.7.2", + "tailwind-merge": "^2.5.2", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@types/node": "^22.5.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.3", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.1" + } +} diff --git a/services/user/Workshop/ui/postcss.config.js b/services/user/Workshop/ui/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/services/user/Workshop/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/services/user/Workshop/ui/src/App.tsx b/services/user/Workshop/ui/src/App.tsx new file mode 100644 index 000000000..98ccf965c --- /dev/null +++ b/services/user/Workshop/ui/src/App.tsx @@ -0,0 +1,13 @@ +import { Workshop } from "./components/Workshop"; +import { Nav } from "./components/nav"; + +function App() { + return ( +
+
+ ); +} + +export default App; diff --git a/services/user/Workshop/ui/src/components/Workshop.tsx b/services/user/Workshop/ui/src/components/Workshop.tsx new file mode 100644 index 000000000..6b32ce5d0 --- /dev/null +++ b/services/user/Workshop/ui/src/components/Workshop.tsx @@ -0,0 +1,162 @@ +import { useLoggedInUser } from "@/hooks/useLoggedInUser"; +import { Button } from "./ui/button"; +import { useCreateConnectionToken } from "@/hooks/useCreateConnectionToken"; +import { useSetMetadata } from "@/hooks/useSetMetadata"; +import { + appMetadataQueryKey, + MetadataResponse, + Status, + useAppMetadata, +} from "@/hooks/useAppMetadata"; +import { Switch } from "@/components/ui/switch"; +import { MetaDataForm } from "./metadata-form"; +import { usePublishApp } from "@/hooks/usePublishApp"; +import { queryClient } from "@/main"; +import { z } from "zod"; +import { toast } from "sonner"; +import { Label } from "./ui/label"; +import { Spinner } from "./ui/spinner"; +import { useBranding } from "@/hooks/useBranding"; +import { ErrorCard } from "./error-card"; +import { IntroCard } from "./intro-card"; + +const setStatus = ( + metadata: z.infer, + published: boolean +): z.infer => ({ + ...metadata, + extraMetadata: { + ...metadata.extraMetadata, + status: published ? Status.Enum.published : Status.Enum.unpublished, + }, +}); + +const setCacheData = (appName: string, checked: boolean) => { + queryClient.setQueryData(appMetadataQueryKey(appName), (data: unknown) => { + if (data) { + return setStatus(MetadataResponse.parse(data), checked); + } + }); +}; + +export const Workshop = () => { + const { + data: currentUser, + isFetched: isFetchedUser, + error: loggedInUserError, + } = useLoggedInUser(); + const { + data: metadata, + isSuccess, + error: metadataError, + } = useAppMetadata(currentUser); + + const { data: networkName } = useBranding(); + + const { mutateAsync: login } = useCreateConnectionToken(); + const { mutateAsync: updateMetadata } = useSetMetadata(); + const { mutateAsync: publishApp } = usePublishApp(); + + const handleChecked = async (checked: boolean, appName: string) => { + try { + setCacheData(appName, checked); + toast(`Updating..`, { + description: `${checked ? "Publishing" : "Unpublishing"} ${appName}`, + }); + void (await publishApp({ account: appName, publish: checked })); + toast(checked ? "Published" : "Unpublished", { + description: checked + ? `${appName} is now published.` + : `${appName} is no longer published.`, + }); + } catch (e) { + toast("Failed to update", { + description: + e instanceof Error ? e.message : "Unrecognised error, see logs.", + }); + setCacheData(appName, !checked); + } + }; + + const isAppPublished = metadata + ? metadata.extraMetadata.status == Status.Enum.published + : false; + + const error = loggedInUserError || metadataError; + const isLoading = !isSuccess; + const isNotLoggedIn = currentUser === null && isFetchedUser; + + if (error) { + return ; + } else if (isNotLoggedIn) { + return ( + { + login(); + }} + /> + ); + } else if (isLoading) { + return ( +
+
+
+ +
+
+ {isFetchedUser + ? "Fetching app metadata..." + : "Fetching account status..."} +
+
+
+ ); + } else + return ( +
+
+ +
+
+ {currentUser} +
+ {isSuccess && ( +
+
+ +
+ + handleChecked(checked, z.string().parse(currentUser)) + } + /> +
+ )} +
+ + {isSuccess && ( + { + await updateMetadata({ + ...x, + owners: [], + }); + return x; + }} + /> + )} +
+
+ ); +}; diff --git a/services/user/Workshop/ui/src/components/error-card.tsx b/services/user/Workshop/ui/src/components/error-card.tsx new file mode 100644 index 000000000..e5edb7305 --- /dev/null +++ b/services/user/Workshop/ui/src/components/error-card.tsx @@ -0,0 +1,40 @@ +import { TriangleAlert } from "lucide-react"; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "./ui/card"; +import { Button } from "./ui/button"; + +interface Props { + error: Error; + retry?: () => void; +} + +export const ErrorCard = ({ error, retry }: Props) => { + return ( + + +
+ +
+ Uh oh + An error occured. + {error.message} + {retry && ( + + + + )} +
+
+ ); +}; diff --git a/services/user/Workshop/ui/src/components/intro-card.tsx b/services/user/Workshop/ui/src/components/intro-card.tsx new file mode 100644 index 000000000..08f4fb50f --- /dev/null +++ b/services/user/Workshop/ui/src/components/intro-card.tsx @@ -0,0 +1,42 @@ +import { Drill } from "lucide-react"; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "./ui/card"; +import { Button } from "./ui/button"; + +interface Props { + networkName?: string; + onLogin: () => void; +} + +export const IntroCard = ({ networkName, onLogin }: Props) => { + return ( + + +
+ +
+ Workshop + + {`The workshop app allows developers to deploy apps on the ${ + networkName ? `${networkName} ` : "" + }network.`} + + Select your app account to continue + + + +
+
+ ); +}; diff --git a/services/user/Workshop/ui/src/components/metadata-form.tsx b/services/user/Workshop/ui/src/components/metadata-form.tsx new file mode 100644 index 000000000..03362c84d --- /dev/null +++ b/services/user/Workshop/ui/src/components/metadata-form.tsx @@ -0,0 +1,267 @@ +import { useForm } from "react-hook-form"; +import { z, ZodError } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + FormRootError, +} from "./ui/form"; +import { Input } from "./ui/input"; +import { Textarea } from "./ui/textarea"; +import { Button } from "./ui/button"; +import { toast } from "sonner"; +import { useState } from "react"; +import { fileToBase64 } from "@/lib/fileToBase64"; +import { Trash } from "lucide-react"; + +const formSchema = z.object({ + name: z.string(), + shortDescription: z.string(), + longDescription: z.string(), + icon: z.string(), + iconMimeType: z.string(), // MIME type of the icon + tosSubpage: z.string(), + privacyPolicySubpage: z.string(), + appHomepageSubpage: z.string(), + redirectUris: z.string().array(), // List of redirect URIs + tags: z.string().array(), // List of tags +}); + +export type Schema = z.infer; + +interface Props { + existingValues?: Schema; + onSubmit: (data: Schema) => Promise; +} + +const blankDefaultValues = formSchema.parse({ + name: "", + shortDescription: "", + longDescription: "", + icon: "", + iconMimeType: "", + tosSubpage: "", + privacyPolicySubpage: "", + appHomepageSubpage: "", + redirectUris: [], + tags: [], +}); + +export const MetaDataForm = ({ existingValues, onSubmit }: Props) => { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: existingValues || blankDefaultValues, + }); + + const [iconPreview, setIconPreview] = useState(null); + + const submit = async (data: Schema) => { + try { + form.clearErrors(); + toast("Saving..."); + await onSubmit({ + ...data, + tosSubpage: data.tosSubpage || "/", + appHomepageSubpage: data.appHomepageSubpage || "/", + privacyPolicySubpage: data.privacyPolicySubpage || "/", + }); + form.reset(data); + toast("Success", { description: `Saved changes.` }); + } catch (error) { + if (error instanceof ZodError) { + error.issues.forEach((field) => { + form.setError(field.path[0] as keyof Schema, { + message: field.message, + }); + }); + toast("Invalid submssion", { + description: "One or more fields are invalid.", + }); + } else if (error instanceof Error) { + form.setError("root", { + message: + error instanceof Error + ? error.message + : "Unrecognised error, see log.", + }); + toast("Request failed", { description: error.message }); + } + } + }; + + const isIcon = + iconPreview || + (existingValues && existingValues.icon && existingValues.iconMimeType); + const currentSrc = `data:${existingValues?.iconMimeType};base64,${existingValues?.icon}`; + + const handleIconChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const mimeType = file.type; + const base64Icon = await fileToBase64(file); + + const iconSrc = `data:${mimeType};base64,${base64Icon}`; + form.setValue("icon", base64Icon); + form.setValue("iconMimeType", mimeType); + setIconPreview(iconSrc); + + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onloadend = () => { + setIconPreview(reader.result as string); + }; + } + }; + + return ( +
+ + ( + + App Name + + + + + + )} + /> +
+ ( + + Tagline + + + + Brief description of the app. + + + )} + /> + ( + + Icon + +
+ + {isIcon && ( + Icon preview + )} + {iconPreview && ( + + )} +
+
+ + + Upload an icon for your app (recommended size: 512x512px) + + +
+ )} + /> +
+ ( + + Long Description + +