Skip to content

Commit 852de25

Browse files
authored
allow image uploads and settings version label (#1671)
* fix * copy changes * Fix copy * works * remove unused * remove unused
1 parent e01382a commit 852de25

File tree

7 files changed

+922
-234
lines changed

7 files changed

+922
-234
lines changed

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
"eslint.format.enable": true,
77
"editor.formatOnSave": true,
88
"editor.defaultFormatter": "esbenp.prettier-vscode",
9-
"typescript.tsdk": "node_modules/typescript/lib"
9+
"typescript.tsdk": "website/node_modules/typescript/lib",
10+
"typescript.enablePromptUseWorkspaceTsdk": true
1011
}

pnpm-lock.yaml

Lines changed: 695 additions & 197 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

website/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,17 @@
2525
"class-variance-authority": "^0.7.0",
2626
"clsx": "^2.1.1",
2727
"connectkit": "^1.7.3",
28+
"file-type": "^18.2.1",
2829
"kubo-rpc-client": "^4.1.1",
2930
"react": "^18.2.0",
3031
"react-dom": "^18.2.0",
32+
"react-dropzone": "^14.2.3",
3133
"react-hook-form": "^7.51.5",
34+
"semver": "7.4.0",
3235
"tailwind-merge": "^2.3.0",
3336
"tailwindcss-animate": "^1.0.7",
3437
"viem": "^2.11.0",
38+
"vite-plugin-node-polyfills": "^0.22.0",
3539
"wagmi": "^2.9.2",
3640
"yaml": "1.10.2",
3741
"zod": "^3.23.8"
@@ -42,6 +46,7 @@
4246
"@types/node": "^20.6.2",
4347
"@types/react": "^18.2.66",
4448
"@types/react-dom": "^18.2.22",
49+
"@types/semver": "^7.3.13",
4550
"@typescript-eslint/eslint-plugin": "^7.2.0",
4651
"@typescript-eslint/parser": "^7.2.0",
4752
"@vitejs/plugin-react": "^4.2.1",

website/src/components/Dropzone.tsx

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { fileTypeFromBuffer } from 'file-type/core';
2+
import { useDropzone } from 'react-dropzone';
3+
import type { FileRejection } from 'react-dropzone';
4+
import { uploadFileToIpfs } from '@/lib/ipfs';
5+
import { ImageIcon } from '@radix-ui/react-icons';
6+
import { Input } from './ui/input';
7+
import { useToast } from './ui/use-toast';
8+
9+
const ACCEPTED_IMAGE_TYPES = {
10+
'image/png': ['.png'],
11+
'image/jpg': ['.jpg'],
12+
'image/jpeg': ['.jpeg'],
13+
};
14+
15+
// Limit avatar images to 1mb
16+
const MAX_IMAGE_SIZE = 1000000;
17+
18+
const BOX_SIZE = 152;
19+
20+
/**
21+
* Format bytes as human-readable text.
22+
* Copied from https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
23+
*/
24+
function humanFileSize(bytes: number, si = false, dp = 1) {
25+
const thresh = si ? 1000 : 1024;
26+
27+
if (Math.abs(bytes) < thresh) {
28+
return bytes + ' B';
29+
}
30+
31+
const units = si
32+
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
33+
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
34+
let u = -1;
35+
const r = 10 ** dp;
36+
37+
do {
38+
bytes /= thresh;
39+
++u;
40+
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
41+
42+
return bytes.toFixed(dp) + ' ' + units[u];
43+
}
44+
45+
export function SubgraphImageDropZone(props: React.InputHTMLAttributes<HTMLInputElement>) {
46+
const { toast } = useToast();
47+
48+
const onDropAccepted = async (files: File[]) => {
49+
const file = files[0];
50+
if (!file) return;
51+
52+
const buffer = await file.arrayBuffer();
53+
const fileType = await fileTypeFromBuffer(buffer);
54+
55+
//check if file type is supported
56+
if (fileType?.mime && !Object.keys(ACCEPTED_IMAGE_TYPES).includes(fileType.mime)) {
57+
toast({
58+
description: 'Images can only be JPG and PNG. Please try again.',
59+
variant: 'destructive',
60+
});
61+
return;
62+
}
63+
};
64+
65+
const onDropRejected = (fileRejections: FileRejection[]) => {
66+
// Format file size error properly
67+
if (fileRejections[0]?.errors[0]?.code === 'file-too-large') {
68+
toast({
69+
description: `File is larger than ${humanFileSize(MAX_IMAGE_SIZE, true)}`,
70+
variant: 'destructive',
71+
});
72+
return;
73+
}
74+
};
75+
76+
const { getRootProps, getInputProps } = useDropzone({
77+
onDropAccepted,
78+
onDropRejected,
79+
accept: ACCEPTED_IMAGE_TYPES,
80+
multiple: false,
81+
maxFiles: 1,
82+
maxSize: MAX_IMAGE_SIZE,
83+
});
84+
85+
const imgSrc = props.value ? (typeof props.value === 'string' ? props.value : null) : null;
86+
87+
return (
88+
<div className="relative flex flex-col gap-y-2 md:flex-row md:gap-x-4 items-center">
89+
<div {...getRootProps()}>
90+
<Input
91+
id="picture"
92+
type="file"
93+
{...getInputProps({
94+
onChange: async a => {
95+
const file = a.target.files?.[0];
96+
97+
if (!file) {
98+
toast({
99+
description: 'No file selected',
100+
variant: 'destructive',
101+
});
102+
return;
103+
}
104+
105+
const buffer = await file.arrayBuffer();
106+
107+
// upload file to ipfs
108+
const ipfsHash = await uploadFileToIpfs({
109+
path: file.name,
110+
content: Buffer.from(buffer),
111+
});
112+
113+
props.onChange?.({
114+
...a,
115+
target: {
116+
...a.target,
117+
value: `https://api.thegraph.com/ipfs/api/v0/cat?arg=${ipfsHash}`,
118+
},
119+
});
120+
},
121+
})}
122+
/>
123+
<div className="flex aspect-square h-[152px] w-[152px] cursor-pointer flex-col items-center justify-center gap-y-1 rounded-md border text-center text-base400 hover:opacity-60">
124+
{imgSrc ? (
125+
<img
126+
src={imgSrc}
127+
alt="subgraph image"
128+
width={BOX_SIZE}
129+
height={BOX_SIZE}
130+
className="h-full rounded-md object-cover"
131+
/>
132+
) : (
133+
<>
134+
<ImageIcon />
135+
<p className="hidden font-base text-base-s lg:block">
136+
Drag your image here or click to upload
137+
</p>
138+
<p className="font-base text-base-s lg:hidden">Tap to upload</p>
139+
</>
140+
)}
141+
</div>
142+
</div>
143+
<div>
144+
<p className="w-full font-base text-base-s text-base400">
145+
Max {humanFileSize(MAX_IMAGE_SIZE, true)}. PNG or JPG only.
146+
<br />
147+
800x800 px recommended
148+
</p>
149+
</div>
150+
</div>
151+
);
152+
}

website/src/routes/deploy.lazy.tsx

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { useState } from 'react';
22
import { ConnectKitButton, useModal } from 'connectkit';
33
import { useForm } from 'react-hook-form';
4+
import semver from 'semver';
45
import { Address } from 'viem';
56
import { useAccount, useSwitchChain, useWriteContract } from 'wagmi';
67
import yaml from 'yaml';
78
import { z } from 'zod';
9+
import { SubgraphImageDropZone } from '@/components/Dropzone';
810
import { Editor } from '@/components/Editor';
911
import { Button } from '@/components/ui/button';
1012
import {
@@ -46,16 +48,33 @@ const getChainInfo = (chain: keyof typeof SUPPORTED_CHAIN) => {
4648
return SUPPORTED_CHAIN[chain];
4749
};
4850

51+
const publishToCopy = (chain: ReturnType<typeof getChainInfo>['chainId']) => {
52+
switch (chain) {
53+
case 42161:
54+
return 'The Graph Network';
55+
case 421614:
56+
return 'The Graph Testnet (not meant for production workload)';
57+
}
58+
};
59+
4960
const subgraphMetadataSchema = z.object({
5061
description: z.string().optional(),
5162
displayName: z.string(),
52-
image: z.string().transform(value => {
53-
return value.startsWith('ipfs://') ? value : `ipfs://${value}`;
54-
}),
5563
subgraphImage: z.string().url(),
5664
codeRepository: z.string().url().optional(),
5765
website: z.string().url().optional(),
5866
categories: z.array(z.string()).optional(),
67+
versionLabel: z.string().superRefine((value, ctx) => {
68+
if (!semver.valid(value)) {
69+
ctx.addIssue({
70+
code: 'custom',
71+
message: 'Not a valid semver version. Example: 0.0.1',
72+
});
73+
return false;
74+
}
75+
76+
return true;
77+
}),
5978
chain: z.enum(CHAINS),
6079
});
6180

@@ -65,12 +84,21 @@ const subgraphMetadata = ({
6584
codeRepository,
6685
website,
6786
categories,
68-
image,
6987
subgraphImage,
7088
}: z.infer<typeof subgraphMetadataSchema>) => {
7189
return {
7290
description,
73-
image,
91+
image: (() => {
92+
const match = subgraphImage.match(/[?&]arg=([^&]+)/);
93+
94+
const hash = match?.[1];
95+
96+
if (!hash) {
97+
throw new Error('Invalid IPFS hash');
98+
}
99+
100+
return `ipfs://${hash}`;
101+
})(),
74102
subgraphImage,
75103
displayName,
76104
name: displayName,
@@ -114,9 +142,9 @@ function DeploySubgraph({ deploymentId }: { deploymentId: string }) {
114142

115143
const form = useForm<z.infer<typeof subgraphMetadataSchema>>({
116144
resolver: zodResolver(subgraphMetadataSchema),
145+
mode: 'all',
117146
defaultValues: {
118147
description: subgraphManifest?.parsed.description,
119-
image: 'ipfs://QmeFs3a4d7kQKuGbV2Ujb5B7ZN8Ph61W5gFfF2mKg2SBtB',
120148
subgraphImage:
121149
'https://api.thegraph.com/ipfs/api/v0/cat?arg=QmdSeSQ3APFjLktQY3aNVu3M5QXPfE9ZRK5LqgghRgB7L9',
122150
codeRepository: subgraphManifest?.parsed.repository,
@@ -143,7 +171,7 @@ function DeploySubgraph({ deploymentId }: { deploymentId: string }) {
143171

144172
const versionMeta = await uploadFileToIpfs({
145173
path: '',
146-
content: Buffer.from(JSON.stringify({ label: '0.0.3', description: null })),
174+
content: Buffer.from(JSON.stringify({ label: values.versionLabel, description: null })),
147175
});
148176

149177
const subgraphMeta = await uploadFileToIpfs({
@@ -193,13 +221,28 @@ function DeploySubgraph({ deploymentId }: { deploymentId: string }) {
193221
render={({ field }) => (
194222
<FormItem>
195223
<FormLabel>Display Name*</FormLabel>
224+
<FormControl>
225+
<Input {...field} placeholder="Name to display on the Graph Explorer" />
226+
</FormControl>
227+
<FormMessage />
228+
</FormItem>
229+
)}
230+
/>
231+
232+
<FormField
233+
control={form.control}
234+
name="versionLabel"
235+
render={({ field }) => (
236+
<FormItem>
237+
<FormLabel>Version Label*</FormLabel>
196238
<FormControl>
197239
<Input {...field} />
198240
</FormControl>
199241
<FormMessage />
200242
</FormItem>
201243
)}
202244
/>
245+
203246
<FormField
204247
control={form.control}
205248
name="description"
@@ -240,6 +283,20 @@ function DeploySubgraph({ deploymentId }: { deploymentId: string }) {
240283
)}
241284
/>
242285

286+
<FormField
287+
control={form.control}
288+
name="subgraphImage"
289+
render={({ field }) => (
290+
<FormItem>
291+
<FormLabel>Subgraph Image</FormLabel>
292+
<FormControl>
293+
<SubgraphImageDropZone {...field} />
294+
</FormControl>
295+
<FormMessage />
296+
</FormItem>
297+
)}
298+
/>
299+
243300
<FormField
244301
control={form.control}
245302
name="chain"
@@ -249,13 +306,13 @@ function DeploySubgraph({ deploymentId }: { deploymentId: string }) {
249306
<Select onValueChange={field.onChange} defaultValue={field.value}>
250307
<FormControl>
251308
<SelectTrigger>
252-
<SelectValue placeholder="Select the chain to publish subgraph to" />
309+
<SelectValue placeholder="Select the network to deploy" />
253310
</SelectTrigger>
254311
</FormControl>
255312
<SelectContent>
256313
{Object.entries(SUPPORTED_CHAIN).map(([chainName, { chainId }]) => (
257314
<SelectItem key={chainId} value={chainName}>
258-
{chainName} (eip-{chainId})
315+
{publishToCopy(chainId)}
259316
</SelectItem>
260317
))}
261318
</SelectContent>

website/test.mjs

Lines changed: 0 additions & 26 deletions
This file was deleted.

website/vite.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import path from 'path';
22
import { defineConfig } from 'vite';
3+
import { nodePolyfills } from 'vite-plugin-node-polyfills';
34
import { TanStackRouterVite } from '@tanstack/router-vite-plugin';
45
import react from '@vitejs/plugin-react';
56

67
export default defineConfig({
7-
plugins: [react(), TanStackRouterVite()],
8+
plugins: [react(), nodePolyfills(), TanStackRouterVite()],
89
resolve: {
910
alias: {
1011
'@': path.resolve(__dirname, './src'),

0 commit comments

Comments
 (0)